diff --git a/Dockerfile b/Dockerfile index a87ddf4..ddd214d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,12 @@ COPY --from=builder /app/packages/web/dist packages/web/dist/ COPY --from=builder /app/node_modules node_modules/ COPY --from=builder /app/echoboard.plugins.ts ./ +RUN addgroup -g 1001 echoboard && adduser -u 1001 -G echoboard -D echoboard && \ + mkdir -p /app/packages/api/uploads && chown -R echoboard:echoboard /app + ENV NODE_ENV=production EXPOSE 3000 +USER echoboard + CMD ["sh", "-c", "npx prisma migrate deploy --schema=packages/api/prisma/schema.prisma && node packages/api/dist/index.js"] diff --git a/docker-compose.yml b/docker-compose.yml index 842af6e..bb52829 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,41 +1,32 @@ -version: "3.8" +name: echoboard services: app: build: . - ports: - - "${PORT:-3000}:3000" - environment: - DATABASE_URL: postgresql://echoboard:${DB_PASSWORD}@db:5432/echoboard - APP_MASTER_KEY: ${APP_MASTER_KEY} - APP_BLIND_INDEX_KEY: ${APP_BLIND_INDEX_KEY} - TOKEN_SECRET: ${TOKEN_SECRET} - JWT_SECRET: ${JWT_SECRET} - ALTCHA_HMAC_KEY: ${ALTCHA_HMAC_KEY} - WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-Echoboard} - WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID} - WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN} - VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} - VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} - VAPID_CONTACT: ${VAPID_CONTACT} - NODE_ENV: production + restart: unless-stopped depends_on: db: condition: service_healthy + env_file: .env + environment: + DATABASE_URL: postgresql://echoboard:${POSTGRES_PASSWORD}@db:5432/echoboard + NODE_ENV: production + ports: + - "${PORT:-3000}:${PORT:-3000}" + volumes: + - ./uploads:/app/packages/api/uploads db: image: postgres:16-alpine + restart: unless-stopped environment: - POSTGRES_DB: echoboard POSTGRES_USER: echoboard - POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: echoboard volumes: - - pgdata:/var/lib/postgresql/data + - ./data/postgres:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U echoboard"] interval: 5s timeout: 3s retries: 5 - -volumes: - pgdata: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..319383b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,7507 @@ +{ + "name": "echoboard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "echoboard", + "version": "0.1.0", + "license": "CC0-1.0", + "workspaces": [ + "packages/api", + "packages/web", + "plugins/*" + ], + "devDependencies": { + "concurrently": "^9.1.0", + "typescript": "^5.7.0" + } + }, + "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.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "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/@echoboard/api": { + "resolved": "packages/api", + "link": true + }, + "node_modules/@echoboard/plugin-gitea": { + "resolved": "plugins/gitea", + "link": true + }, + "node_modules/@echoboard/web": { + "resolved": "packages/web", + "link": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@fastify/cookie": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", + "integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^1.0.0", + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz", + "integrity": "sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "mnemonist": "0.40.0" + } + }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", + "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/multipart": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-9.4.0.tgz", + "integrity": "sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^5.0.0", + "secure-json-parse": "^4.0.0" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/rate-limit": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", + "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-8.3.0.tgz", + "integrity": "sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^0.5.4", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^11.0.0" + } + }, + "node_modules/@fontsource/sora": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/sora/-/sora-5.2.8.tgz", + "integrity": "sha512-1G6iTXUx8rcCKzi3mjaTQ1DE8PQz0OmW3Qnku+64S+bqRr1o/gGeiw8fxIQhhBU9ZP8ZofIqai7o00DNOPnlDw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/space-grotesk": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/@fontsource/space-grotesk/-/space-grotesk-5.2.10.tgz", + "integrity": "sha512-XNXEbT74OIITPqw2H6HXwPDp85fy43uxfBwFR5PU+9sLnjuLj12KlhVM9nZVN6q6dlKjkuN8JisW/OBxwxgUew==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mapbox/node-pre-gyp/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/@peculiar/asn1-android": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz", + "integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", + "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz", + "integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz", + "integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@prisma/client": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz", + "integrity": "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.2.tgz", + "integrity": "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.2.tgz", + "integrity": "sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.2.tgz", + "integrity": "sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.2", + "@prisma/get-platform": "6.19.2" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.2.tgz", + "integrity": "sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.2" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.2.tgz", + "integrity": "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@simplewebauthn/browser": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-11.0.0.tgz", + "integrity": "sha512-KEGCStrl08QC2I561BzxqGiwoknblP6O1YW7jApdXLPtIqZ+vgJYAv8ssLCdm1wD8HGAHd49CJLkUF8X70x/pg==", + "license": "MIT", + "dependencies": { + "@simplewebauthn/types": "^11.0.0" + } + }, + "node_modules/@simplewebauthn/server": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-11.0.0.tgz", + "integrity": "sha512-zu8dxKcPiRUNSN2kmrnNOzNbRI8VaR/rL4ENCHUfC6PEE7SAAdIql9g5GBOd/wOVZolIsaZz3ccFxuGoVP0iaw==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.3.10", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@simplewebauthn/types": "^11.0.0", + "cross-fetch": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@simplewebauthn/types": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-11.0.0.tgz", + "integrity": "sha512-b2o0wC5u2rWts31dTgBkAtSNKGX0cvL6h8QedNsKmj8O4QoLFQFR3DBVBUlpyVEhYKA+mXGUaXbcOc4JdQ3HzA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tabler/icons": { + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.40.0.tgz", + "integrity": "sha512-V/Q4VgNPKubRTiLdmWjV/zscYcj5IIk+euicUtaVVqF6luSC9rDngYWgST5/yh3Mrg/mYUwRv1YVTk71Jp0twQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.40.0.tgz", + "integrity": "sha512-oO5+6QCnna4a//mYubx4euZfECtzQZFDGsDMIdzZUhbdyBCT+3bRVFBPueGIcemWld4Vb/0UQ39C/cmGfGylAg==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.40.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "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.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "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/bcrypt": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", + "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, + "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/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/rss": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/@types/rss/-/rss-0.0.32.tgz", + "integrity": "sha512-2oKNqKyUY4RSdvl5eZR1n2Q9yvw3XTe3mQHsFPn9alaNBxfPnbXBtGP8R0SV8pK1PrVnLul0zx7izbm5/gF5Qw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/altcha-lib": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/altcha-lib/-/altcha-lib-0.5.1.tgz", + "integrity": "sha512-dW3g79ZoXRh2/fDBUj+T9rKWFyDMwxIAPdCOuQ7rAe4RIwXNMEiJHrXPZhmj7aALTKRqHCmMB4BMBkyBe1+gww==", + "license": "MIT" + }, + "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==", + "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/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", + "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "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/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/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "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/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "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/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "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/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "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/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/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "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/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "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/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "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/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true, + "license": "ISC" + }, + "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==", + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "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/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.2.tgz", + "integrity": "sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastify/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/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-my-way": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "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/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "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/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/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "dev": true, + "license": "ISC" + }, + "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/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "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/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, + "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==", + "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/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/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/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/jsonwebtoken/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/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/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "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.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-newline-to-break": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", + "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-find-and-replace": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.25.0.tgz", + "integrity": "sha512-5k547tI4Cy+Lddr/hdjNbBEWBwSl8EBc5aSdKvedav8DReADgWJzcYiktaRIw3GtGC1jjwldXtTzvqJZmtvC7w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.13.tgz", + "integrity": "sha512-ryBDp1Z/6X90UvjUK3RksH0IBPM137T7cmg4OgD5wQBojlAiUwuok0QeELkim/72EtcYuNlmbkrcGuxj3Kl0YQ==", + "license": "MIT", + "dependencies": { + "mime-db": "~1.25.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "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/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mnemonist": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz", + "integrity": "sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.4" + } + }, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", + "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": 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==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prisma": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz", + "integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.2", + "@prisma/engines": "6.19.2" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/remark-breaks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", + "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-newline-to-break": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.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/rss": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/rss/-/rss-1.2.2.tgz", + "integrity": "sha512-xUhRTgslHeCBeHAqaWSbOYTydN2f0tAzNXvzh3stjz7QDhQMzdgHf3pfgNIngeytQflrFPfy6axHilTETr6gDg==", + "license": "MIT", + "dependencies": { + "mime-types": "2.1.13", + "xml": "1.0.1" + } + }, + "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/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/safe-regex2": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz", + "integrity": "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "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/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/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==", + "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/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "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/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "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/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "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/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "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/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "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/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/web-push/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/web-push/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "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/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "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/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "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" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "packages/api": { + "name": "@echoboard/api", + "version": "0.1.0", + "dependencies": { + "@fastify/cookie": "^11.0.0", + "@fastify/cors": "^10.0.0", + "@fastify/multipart": "^9.4.0", + "@fastify/rate-limit": "^10.0.0", + "@fastify/static": "^8.0.0", + "@prisma/client": "^6.0.0", + "@simplewebauthn/server": "^11.0.0", + "altcha-lib": "^0.5.0", + "bcrypt": "^5.1.0", + "fastify": "^5.0.0", + "fastify-plugin": "^5.0.0", + "jsonwebtoken": "^9.0.0", + "node-cron": "^3.0.0", + "rss": "^1.2.0", + "web-push": "^3.6.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/bcrypt": "^5.0.0", + "@types/jsonwebtoken": "^9.0.0", + "@types/node": "^22.0.0", + "@types/node-cron": "^3.0.11", + "@types/rss": "^0.0.32", + "@types/web-push": "^3.6.0", + "prisma": "^6.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } + }, + "packages/web": { + "name": "@echoboard/web", + "version": "0.1.0", + "dependencies": { + "@fontsource/sora": "^5.0.0", + "@fontsource/space-grotesk": "^5.0.0", + "@simplewebauthn/browser": "^11.0.0", + "@tabler/icons-react": "^3.40.0", + "dompurify": "^3.3.3", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-markdown": "^10.1.0", + "react-router-dom": "^7.0.0", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/dompurify": "^3.0.5", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } + }, + "plugins/gitea": { + "name": "@echoboard/plugin-gitea", + "version": "0.1.0", + "dependencies": { + "@echoboard/api": "*" + }, + "devDependencies": { + "typescript": "^5.7.0" + } + } + } +} diff --git a/packages/api/package.json b/packages/api/package.json index 37d8fe8..86c8bfc 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -5,7 +5,7 @@ "type": "module", "main": "dist/index.js", "scripts": { - "dev": "tsx watch src/index.ts", + "dev": "tsx watch --env-file=../../.env src/index.ts", "build": "tsc", "start": "node dist/index.js", "db:migrate": "prisma migrate dev", @@ -16,6 +16,7 @@ "dependencies": { "@fastify/cookie": "^11.0.0", "@fastify/cors": "^10.0.0", + "@fastify/multipart": "^9.4.0", "@fastify/rate-limit": "^10.0.0", "@fastify/static": "^8.0.0", "@prisma/client": "^6.0.0", @@ -34,6 +35,8 @@ "@types/bcrypt": "^5.0.0", "@types/jsonwebtoken": "^9.0.0", "@types/node": "^22.0.0", + "@types/node-cron": "^3.0.11", + "@types/rss": "^0.0.32", "@types/web-push": "^3.6.0", "prisma": "^6.0.0", "tsx": "^4.19.0", diff --git a/packages/api/prisma/migrations/20260319185954_init/migration.sql b/packages/api/prisma/migrations/20260319185954_init/migration.sql new file mode 100644 index 0000000..65a4b96 --- /dev/null +++ b/packages/api/prisma/migrations/20260319185954_init/migration.sql @@ -0,0 +1,270 @@ +-- CreateEnum +CREATE TYPE "AuthMethod" AS ENUM ('COOKIE', 'PASSKEY'); + +-- CreateEnum +CREATE TYPE "PostType" AS ENUM ('FEATURE_REQUEST', 'BUG_REPORT'); + +-- CreateEnum +CREATE TYPE "PostStatus" AS ENUM ('OPEN', 'UNDER_REVIEW', 'PLANNED', 'IN_PROGRESS', 'DONE', 'DECLINED'); + +-- CreateTable +CREATE TABLE "Board" ( + "id" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "externalUrl" TEXT, + "isArchived" BOOLEAN NOT NULL DEFAULT false, + "voteBudget" INTEGER NOT NULL DEFAULT 10, + "voteBudgetReset" TEXT NOT NULL DEFAULT 'monthly', + "lastBudgetReset" TIMESTAMP(3), + "allowMultiVote" BOOLEAN NOT NULL DEFAULT false, + "rssEnabled" BOOLEAN NOT NULL DEFAULT true, + "rssFeedCount" INTEGER NOT NULL DEFAULT 50, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Board_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "authMethod" "AuthMethod" NOT NULL DEFAULT 'COOKIE', + "tokenHash" TEXT, + "username" TEXT, + "usernameIdx" TEXT, + "displayName" TEXT, + "darkMode" TEXT NOT NULL DEFAULT 'system', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Passkey" ( + "id" TEXT NOT NULL, + "credentialId" TEXT NOT NULL, + "credentialIdIdx" TEXT NOT NULL, + "credentialPublicKey" BYTEA NOT NULL, + "counter" BIGINT NOT NULL, + "credentialDeviceType" TEXT NOT NULL, + "credentialBackedUp" BOOLEAN NOT NULL, + "transports" TEXT, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Passkey_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Post" ( + "id" TEXT NOT NULL, + "type" "PostType" NOT NULL, + "title" TEXT NOT NULL, + "description" JSONB NOT NULL, + "status" "PostStatus" NOT NULL DEFAULT 'OPEN', + "category" TEXT, + "voteCount" INTEGER NOT NULL DEFAULT 0, + "isPinned" BOOLEAN NOT NULL DEFAULT false, + "boardId" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Post_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "StatusChange" ( + "id" TEXT NOT NULL, + "postId" TEXT NOT NULL, + "fromStatus" "PostStatus" NOT NULL, + "toStatus" "PostStatus" NOT NULL, + "changedBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "StatusChange_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Comment" ( + "id" TEXT NOT NULL, + "body" TEXT NOT NULL, + "postId" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Comment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Reaction" ( + "id" TEXT NOT NULL, + "emoji" TEXT NOT NULL, + "commentId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Reaction_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Vote" ( + "id" TEXT NOT NULL, + "weight" INTEGER NOT NULL DEFAULT 1, + "postId" TEXT NOT NULL, + "voterId" TEXT NOT NULL, + "budgetPeriod" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Vote_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AdminUser" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AdminResponse" ( + "id" TEXT NOT NULL, + "body" TEXT NOT NULL, + "postId" TEXT NOT NULL, + "adminId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AdminResponse_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ActivityEvent" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "boardId" TEXT NOT NULL, + "postId" TEXT, + "metadata" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ActivityEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PushSubscription" ( + "id" TEXT NOT NULL, + "endpoint" TEXT NOT NULL, + "endpointIdx" TEXT NOT NULL, + "keysP256dh" TEXT NOT NULL, + "keysAuth" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "boardId" TEXT, + "postId" TEXT, + "failureCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Category" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Category_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Board_slug_key" ON "Board"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_tokenHash_key" ON "User"("tokenHash"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_usernameIdx_key" ON "User"("usernameIdx"); + +-- CreateIndex +CREATE UNIQUE INDEX "Passkey_credentialIdIdx_key" ON "Passkey"("credentialIdIdx"); + +-- CreateIndex +CREATE UNIQUE INDEX "Reaction_commentId_userId_emoji_key" ON "Reaction"("commentId", "userId", "emoji"); + +-- CreateIndex +CREATE UNIQUE INDEX "Vote_postId_voterId_key" ON "Vote"("postId", "voterId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminUser_email_key" ON "AdminUser"("email"); + +-- CreateIndex +CREATE INDEX "ActivityEvent_boardId_createdAt_idx" ON "ActivityEvent"("boardId", "createdAt"); + +-- CreateIndex +CREATE INDEX "ActivityEvent_createdAt_idx" ON "ActivityEvent"("createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "PushSubscription_endpointIdx_key" ON "PushSubscription"("endpointIdx"); + +-- CreateIndex +CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug"); + +-- AddForeignKey +ALTER TABLE "Passkey" ADD CONSTRAINT "Passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Post" ADD CONSTRAINT "Post_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StatusChange" ADD CONSTRAINT "StatusChange_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Vote" ADD CONSTRAINT "Vote_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Vote" ADD CONSTRAINT "Vote_voterId_fkey" FOREIGN KEY ("voterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AdminResponse" ADD CONSTRAINT "AdminResponse_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AdminResponse" ADD CONSTRAINT "AdminResponse_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ActivityEvent" ADD CONSTRAINT "ActivityEvent_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ActivityEvent" ADD CONSTRAINT "ActivityEvent_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/api/prisma/migrations/20260320120000_add_edit_history/migration.sql b/packages/api/prisma/migrations/20260320120000_add_edit_history/migration.sql new file mode 100644 index 0000000..3c9eb31 --- /dev/null +++ b/packages/api/prisma/migrations/20260320120000_add_edit_history/migration.sql @@ -0,0 +1,28 @@ +-- CreateTable +CREATE TABLE "EditHistory" ( + "id" TEXT NOT NULL, + "postId" TEXT, + "commentId" TEXT, + "editedBy" TEXT NOT NULL, + "previousTitle" TEXT, + "previousDescription" JSONB, + "previousBody" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "EditHistory_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "EditHistory_postId_createdAt_idx" ON "EditHistory"("postId", "createdAt"); + +-- CreateIndex +CREATE INDEX "EditHistory_commentId_createdAt_idx" ON "EditHistory"("commentId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "EditHistory" ADD CONSTRAINT "EditHistory_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EditHistory" ADD CONSTRAINT "EditHistory_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EditHistory" ADD CONSTRAINT "EditHistory_editedBy_fkey" FOREIGN KEY ("editedBy") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/api/prisma/migrations/20260320130000_add_edit_lock/migration.sql b/packages/api/prisma/migrations/20260320130000_add_edit_lock/migration.sql new file mode 100644 index 0000000..bcc9c85 --- /dev/null +++ b/packages/api/prisma/migrations/20260320130000_add_edit_lock/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Post" ADD COLUMN "isEditLocked" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/api/prisma/migrations/20260320140000_nullable_edit_history_editor/migration.sql b/packages/api/prisma/migrations/20260320140000_nullable_edit_history_editor/migration.sql new file mode 100644 index 0000000..c7a2969 --- /dev/null +++ b/packages/api/prisma/migrations/20260320140000_nullable_edit_history_editor/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "EditHistory" ALTER COLUMN "editedBy" DROP NOT NULL; + +-- DropForeignKey +ALTER TABLE "EditHistory" DROP CONSTRAINT "EditHistory_editedBy_fkey"; + +-- AddForeignKey +ALTER TABLE "EditHistory" ADD CONSTRAINT "EditHistory_editedBy_fkey" FOREIGN KEY ("editedBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/api/prisma/migrations/20260320150000_add_site_settings/migration.sql b/packages/api/prisma/migrations/20260320150000_add_site_settings/migration.sql new file mode 100644 index 0000000..788e2a2 --- /dev/null +++ b/packages/api/prisma/migrations/20260320150000_add_site_settings/migration.sql @@ -0,0 +1,16 @@ +CREATE TABLE "SiteSettings" ( + "id" TEXT NOT NULL DEFAULT 'default', + "appName" TEXT NOT NULL DEFAULT 'Echoboard', + "logoUrl" TEXT, + "faviconUrl" TEXT, + "accentColor" TEXT NOT NULL DEFAULT '#F59E0B', + "headerFont" TEXT, + "bodyFont" TEXT, + "poweredByVisible" BOOLEAN NOT NULL DEFAULT true, + "customCss" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id") +); + +INSERT INTO "SiteSettings" ("id", "updatedAt") VALUES ('default', NOW()); diff --git a/packages/api/prisma/migrations/migration_lock.toml b/packages/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/packages/api/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index c6da8ea..774abb9 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -17,32 +17,46 @@ enum PostType { BUG_REPORT } -enum PostStatus { - OPEN - UNDER_REVIEW - PLANNED - IN_PROGRESS - DONE - DECLINED -} - model Board { id String @id @default(cuid()) slug String @unique name String description String? externalUrl String? + iconName String? + iconColor String? isArchived Boolean @default(false) voteBudget Int @default(10) voteBudgetReset String @default("monthly") lastBudgetReset DateTime? allowMultiVote Boolean @default(false) + rssEnabled Boolean @default(true) + rssFeedCount Int @default(50) + staleDays Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt posts Post[] activityEvents ActivityEvent[] pushSubscriptions PushSubscription[] + statusConfig BoardStatus[] + templates BoardTemplate[] + changelogEntries ChangelogEntry[] +} + +model BoardStatus { + id String @id @default(cuid()) + boardId String + status String + label String + color String + position Int @default(0) + enabled Boolean @default(true) + + board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) + + @@unique([boardId, status]) + @@index([boardId, position]) } model User { @@ -52,16 +66,35 @@ model User { username String? usernameIdx String? @unique displayName String? + avatarPath String? darkMode String @default("system") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - passkeys Passkey[] - posts Post[] - comments Comment[] - reactions Reaction[] - votes Vote[] + passkeys Passkey[] + posts Post[] + comments Comment[] + reactions Reaction[] + votes Vote[] + notifications Notification[] pushSubscriptions PushSubscription[] + attachments Attachment[] + adminLink AdminUser? + edits EditHistory[] + recoveryCode RecoveryCode? +} + +model RecoveryCode { + id String @id @default(cuid()) + codeHash String + phraseIdx String @unique + userId String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([expiresAt]) } model Passkey { @@ -80,18 +113,26 @@ model Passkey { } model Post { - id String @id @default(cuid()) - type PostType - title String - description Json - status PostStatus @default(OPEN) - category String? - voteCount Int @default(0) - isPinned Boolean @default(false) - boardId String - authorId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + type PostType + title String + description Json + status String @default("OPEN") + statusReason String? + category String? + templateId String? + voteCount Int @default(0) + viewCount Int @default(0) + isPinned Boolean @default(false) + isEditLocked Boolean @default(false) + isThreadLocked Boolean @default(false) + isVotingLocked Boolean @default(false) + onBehalfOf String? + boardId String + authorId String + lastActivityAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) author User @relation(fields: [authorId], references: [id], onDelete: Cascade) @@ -99,15 +140,23 @@ model Post { comments Comment[] votes Vote[] adminResponses AdminResponse[] + adminNotes AdminNote[] + notifications Notification[] activityEvents ActivityEvent[] pushSubscriptions PushSubscription[] + tags PostTag[] + attachments Attachment[] + editHistory EditHistory[] + + @@index([boardId, status]) } model StatusChange { id String @id @default(cuid()) postId String - fromStatus PostStatus - toStatus PostStatus + fromStatus String + toStatus String + reason String? changedBy String createdAt DateTime @default(now()) @@ -115,16 +164,28 @@ model StatusChange { } model Comment { - id String @id @default(cuid()) - body String - postId String - authorId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + body String + postId String + authorId String + replyToId String? + isAdmin Boolean @default(false) + isEditLocked Boolean @default(false) + adminUserId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - post Post @relation(fields: [postId], references: [id], onDelete: Cascade) - author User @relation(fields: [authorId], references: [id], onDelete: Cascade) - reactions Reaction[] + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + replyTo Comment? @relation("CommentReplies", fields: [replyToId], references: [id], onDelete: SetNull) + replies Comment[] @relation("CommentReplies") + adminUser AdminUser? @relation(fields: [adminUserId], references: [id], onDelete: SetNull) + reactions Reaction[] + attachments Attachment[] + editHistory EditHistory[] + + @@index([replyToId]) + @@index([postId, createdAt]) } model Reaction { @@ -143,6 +204,7 @@ model Reaction { model Vote { id String @id @default(cuid()) weight Int @default(1) + importance String? postId String voterId String budgetPeriod String @@ -154,13 +216,50 @@ model Vote { @@unique([postId, voterId]) } -model AdminUser { - id String @id @default(cuid()) - email String @unique - passwordHash String - createdAt DateTime @default(now()) +enum TeamRole { + SUPER_ADMIN + ADMIN + MODERATOR +} - responses AdminResponse[] +model AdminUser { + id String @id @default(cuid()) + email String? @unique + passwordHash String? + role TeamRole @default(ADMIN) + displayName String? + teamTitle String? + linkedUserId String? @unique + invitedById String? + createdAt DateTime @default(now()) + + linkedUser User? @relation(fields: [linkedUserId], references: [id], onDelete: SetNull) + invitedBy AdminUser? @relation("TeamInviter", fields: [invitedById], references: [id], onDelete: SetNull) + invitees AdminUser[] @relation("TeamInviter") + responses AdminResponse[] + notes AdminNote[] + comments Comment[] + createdInvites TeamInvite[] @relation("InviteCreator") + claimedInvite TeamInvite? @relation("InviteClaimer") +} + +model TeamInvite { + id String @id @default(cuid()) + tokenHash String @unique + role TeamRole + label String? + expiresAt DateTime + createdById String + claimedById String? @unique + claimedAt DateTime? + recoveryHash String? + recoveryIdx String? + createdAt DateTime @default(now()) + + createdBy AdminUser @relation("InviteCreator", fields: [createdById], references: [id], onDelete: Cascade) + claimedBy AdminUser? @relation("InviteClaimer", fields: [claimedById], references: [id], onDelete: SetNull) + + @@index([expiresAt]) } model AdminResponse { @@ -190,15 +289,16 @@ model ActivityEvent { } model PushSubscription { - id String @id @default(cuid()) - endpoint String - endpointIdx String @unique - keysP256dh String - keysAuth String - userId String - boardId String? - postId String? - createdAt DateTime @default(now()) + id String @id @default(cuid()) + endpoint String + endpointIdx String @unique + keysP256dh String + keysAuth String + userId String + boardId String? + postId String? + failureCount Int @default(0) + createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) board Board? @relation(fields: [boardId], references: [id], onDelete: Cascade) @@ -211,3 +311,151 @@ model Category { slug String @unique createdAt DateTime @default(now()) } + +model Notification { + id String @id @default(cuid()) + type String + title String + body String + postId String? + userId String + read Boolean @default(false) + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + post Post? @relation(fields: [postId], references: [id], onDelete: SetNull) + + @@index([userId, read, createdAt]) +} + +model Webhook { + id String @id @default(cuid()) + url String + secret String + events String[] @default(["status_changed", "post_created", "comment_added"]) + active Boolean @default(true) + createdAt DateTime @default(now()) +} + +model PostMerge { + id String @id @default(cuid()) + sourcePostId String + targetPostId String + mergedBy String + createdAt DateTime @default(now()) + + @@index([sourcePostId]) +} + +model ChangelogEntry { + id String @id @default(cuid()) + title String + body String + boardId String? + publishedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + board Board? @relation(fields: [boardId], references: [id], onDelete: Cascade) + + @@index([boardId, publishedAt]) +} + +model AdminNote { + id String @id @default(cuid()) + body String + postId String + adminId String + createdAt DateTime @default(now()) + + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + admin AdminUser @relation(fields: [adminId], references: [id], onDelete: Cascade) +} + +model Tag { + id String @id @default(cuid()) + name String @unique + color String @default("#6366F1") + createdAt DateTime @default(now()) + + posts PostTag[] +} + +model PostTag { + postId String + tagId String + + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@id([postId, tagId]) +} + +model Attachment { + id String @id @default(cuid()) + filename String + path String + size Int + mimeType String + postId String? + commentId String? + uploaderId String + createdAt DateTime @default(now()) + + post Post? @relation(fields: [postId], references: [id], onDelete: Cascade) + comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade) + uploader User @relation(fields: [uploaderId], references: [id], onDelete: Cascade) +} + +model BoardTemplate { + id String @id @default(cuid()) + boardId String + name String + fields Json + isDefault Boolean @default(false) + position Int @default(0) + + board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) + + @@index([boardId, position]) +} + +model SiteSettings { + id String @id @default("default") + appName String @default("Echoboard") + logoUrl String? + faviconUrl String? + accentColor String @default("#F59E0B") + headerFont String? + bodyFont String? + poweredByVisible Boolean @default(true) + customCss String? + updatedAt DateTime @updatedAt +} + +model BlockedToken { + id String @id @default(cuid()) + tokenHash String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([expiresAt]) +} + +model EditHistory { + id String @id @default(cuid()) + postId String? + commentId String? + editedBy String? + previousTitle String? + previousDescription Json? + previousBody String? + createdAt DateTime @default(now()) + + post Post? @relation(fields: [postId], references: [id], onDelete: Cascade) + comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade) + editor User? @relation(fields: [editedBy], references: [id], onDelete: SetNull) + + @@index([postId, createdAt]) + @@index([commentId, createdAt]) +} diff --git a/packages/api/prisma/seed.ts b/packages/api/prisma/seed.ts new file mode 100644 index 0000000..49855f7 --- /dev/null +++ b/packages/api/prisma/seed.ts @@ -0,0 +1,1298 @@ +import { PrismaClient, PostType } from "@prisma/client"; +import { createCipheriv, createHmac, randomBytes } from "node:crypto"; + +const prisma = new PrismaClient(); + +const MASTER_KEY = Buffer.from(process.env.APP_MASTER_KEY!, "hex"); +const BLIND_KEY = Buffer.from(process.env.APP_BLIND_INDEX_KEY!, "hex"); + +function encrypt(plaintext: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", MASTER_KEY, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, encrypted, tag]).toString("base64"); +} + +function blindIndex(value: string): string { + return createHmac("sha256", BLIND_KEY).update(value.toLowerCase()).digest("hex"); +} + +function daysAgo(n: number): Date { + return new Date(Date.now() - n * 86400000); +} + +function hoursAgo(n: number): Date { + return new Date(Date.now() - n * 3600000); +} + +async function main() { + console.log("Wiping existing content (keeping accounts)..."); + + // Wipe in dependency order + await prisma.editHistory.deleteMany(); + await prisma.reaction.deleteMany(); + await prisma.attachment.deleteMany(); + await prisma.adminNote.deleteMany(); + await prisma.adminResponse.deleteMany(); + await prisma.notification.deleteMany(); + await prisma.postMerge.deleteMany(); + await prisma.postTag.deleteMany(); + await prisma.comment.deleteMany(); + await prisma.statusChange.deleteMany(); + await prisma.vote.deleteMany(); + await prisma.activityEvent.deleteMany(); + await prisma.pushSubscription.deleteMany(); + await prisma.boardStatus.deleteMany(); + await prisma.boardTemplate.deleteMany(); + await prisma.post.deleteMany(); + await prisma.board.deleteMany(); + await prisma.changelogEntry.deleteMany(); + await prisma.tag.deleteMany(); + await prisma.category.deleteMany(); + await prisma.webhook.deleteMany(); + + console.log("Creating users..."); + + // Keep existing admin and create fresh users with encrypted names + const admin = await prisma.adminUser.findFirst(); + if (!admin) throw new Error("No admin user found"); + + // We'll create 14 users: mix of cookie-only, cookie-with-name, passkey + const userDefs: { name: string | null; auth: "COOKIE" | "PASSKEY"; username?: string }[] = [ + { name: "Marcus", auth: "PASSKEY", username: "marcus" }, + { name: "Lina", auth: "PASSKEY", username: "lina_k" }, + { name: "Tom", auth: "COOKIE" }, + { name: "Priya", auth: "COOKIE" }, + { name: null, auth: "COOKIE" }, // anon1 + { name: "Jesse", auth: "PASSKEY", username: "jesse" }, + { name: "Noor", auth: "COOKIE" }, + { name: null, auth: "COOKIE" }, // anon2 + { name: "Seb", auth: "COOKIE" }, + { name: "Hana", auth: "PASSKEY", username: "hana_m" }, + { name: "Derek", auth: "COOKIE" }, + { name: null, auth: "COOKIE" }, // anon3 + { name: "Yuki", auth: "COOKIE" }, + { name: "Fran", auth: "PASSKEY", username: "fran" }, + ]; + + // Delete old seed users (keep admin-linked user) + const adminLinkedId = admin.linkedUserId; + await prisma.user.deleteMany({ + where: { id: { not: adminLinkedId ?? "nope" } }, + }); + + const users: { id: string; name: string | null }[] = []; + for (const def of userDefs) { + const u = await prisma.user.create({ + data: { + authMethod: def.auth, + displayName: def.name ? encrypt(def.name) : null, + username: def.username ?? null, + usernameIdx: def.username ? blindIndex(def.username) : null, + tokenHash: randomBytes(32).toString("hex"), + }, + }); + users.push({ id: u.id, name: def.name }); + } + + // Helpers + const u = (name: string) => users.find((x) => x.name === name)!.id; + const anons = users.filter((x) => !x.name); + const anon = (i: number) => anons[i].id; + const adminUserId = adminLinkedId!; + const period = new Date().toISOString().slice(0, 7); + + console.log("Creating categories and tags..."); + + const cats = await Promise.all([ + prisma.category.create({ data: { name: "Bug", slug: "bug" } }), + prisma.category.create({ data: { name: "Performance", slug: "performance" } }), + prisma.category.create({ data: { name: "UI/UX", slug: "ui-ux" } }), + prisma.category.create({ data: { name: "Mobile", slug: "mobile" } }), + prisma.category.create({ data: { name: "Integrations", slug: "integrations" } }), + prisma.category.create({ data: { name: "Billing", slug: "billing" } }), + ]); + + const tags = await Promise.all([ + prisma.tag.create({ data: { name: "high-priority", color: "#EF4444" } }), + prisma.tag.create({ data: { name: "quick-win", color: "#22C55E" } }), + prisma.tag.create({ data: { name: "needs-repro", color: "#F59E0B" } }), + prisma.tag.create({ data: { name: "wontfix", color: "#6B7280" } }), + prisma.tag.create({ data: { name: "duplicate", color: "#8B5CF6" } }), + prisma.tag.create({ data: { name: "v2", color: "#3B82F6" } }), + ]); + + const tagId = (n: string) => tags.find((t) => t.name === n)!.id; + const catSlug = (n: string) => cats.find((c) => c.slug === n)!.slug; + + console.log("Creating boards..."); + + const boards = await Promise.all([ + prisma.board.create({ + data: { + slug: "cloudpush", + name: "Cloudpush", + description: "File sync and cloud storage", + iconName: "IconCloud", + iconColor: "#3B82F6", + voteBudget: 10, + voteBudgetReset: "monthly", + staleDays: 60, + }, + }), + prisma.board.create({ + data: { + slug: "mealwise", + name: "Mealwise", + description: "Meal planning and recipes", + iconName: "IconChefHat", + iconColor: "#F97316", + voteBudget: 8, + voteBudgetReset: "biweekly", + }, + }), + prisma.board.create({ + data: { + slug: "trackpad", + name: "Trackpad", + description: "Project management for small teams", + iconName: "IconChecklist", + iconColor: "#8B5CF6", + voteBudget: 12, + voteBudgetReset: "monthly", + }, + }), + prisma.board.create({ + data: { + slug: "pocketcast", + name: "Pocketcast", + description: "Podcast player and discovery", + iconName: "IconHeadphones", + iconColor: "#EC4899", + voteBudget: 10, + voteBudgetReset: "monthly", + }, + }), + prisma.board.create({ + data: { + slug: "greenline", + name: "Greenline", + description: "Public transit and commute planning", + iconName: "IconBus", + iconColor: "#10B981", + voteBudget: 15, + voteBudgetReset: "weekly", + }, + }), + prisma.board.create({ + data: { + slug: "inkwell", + name: "Inkwell", + description: "Writing and note-taking", + iconName: "IconPencil", + iconColor: "#F59E0B", + voteBudget: 10, + voteBudgetReset: "monthly", + allowMultiVote: true, + }, + }), + ]); + + const board = (slug: string) => boards.find((b) => b.slug === slug)!; + + // Helper to create a post with votes, comments, status changes, tags, activity events + interface PostDef { + board: string; + type: PostType; + title: string; + desc: Record; + author: string; + status?: string; + statusReason?: string; + category?: string; + pinned?: boolean; + editLocked?: boolean; + votes?: { user: string; weight?: number; importance?: string }[]; + tags?: string[]; + statusChanges?: { from: string; to: string; reason?: string; daysAgo: number }[]; + comments?: CommentDef[]; + onBehalfOf?: string; + createdDaysAgo: number; + edited?: { prevTitle?: string; prevDesc?: Record; daysAgo: number }; + } + + interface CommentDef { + body: string; + author: string; + isAdmin?: boolean; + daysAgo: number; + reactions?: { emoji: string; users: string[] }[]; + replies?: CommentDef[]; + edited?: { prevBody: string; daysAgo: number }; + } + + async function createPost(def: PostDef) { + const b = board(def.board); + const created = daysAgo(def.createdDaysAgo); + const post = await prisma.post.create({ + data: { + type: def.type, + title: def.title, + description: def.desc, + status: def.status ?? "OPEN", + statusReason: def.statusReason, + category: def.category, + isPinned: def.pinned ?? false, + isEditLocked: def.editLocked ?? false, + onBehalfOf: def.onBehalfOf, + boardId: b.id, + authorId: def.author, + voteCount: 0, + lastActivityAt: created, + createdAt: created, + updatedAt: created, + }, + }); + + // Votes + let voteCount = 0; + for (const v of def.votes ?? []) { + await prisma.vote.create({ + data: { + postId: post.id, + voterId: v.user, + weight: v.weight ?? 1, + importance: v.importance, + budgetPeriod: period, + createdAt: daysAgo(def.createdDaysAgo - 1 + Math.random() * def.createdDaysAgo), + }, + }); + voteCount += v.weight ?? 1; + } + await prisma.post.update({ where: { id: post.id }, data: { voteCount } }); + + // Tags + for (const t of def.tags ?? []) { + await prisma.postTag.create({ data: { postId: post.id, tagId: tagId(t) } }); + } + + // Status changes + for (const sc of def.statusChanges ?? []) { + await prisma.statusChange.create({ + data: { + postId: post.id, + fromStatus: sc.from, + toStatus: sc.to, + reason: sc.reason, + changedBy: admin.id, + createdAt: daysAgo(sc.daysAgo), + }, + }); + } + + // Comments (recursive for replies) + async function createComment(cdef: CommentDef, parentId?: string) { + const c = await prisma.comment.create({ + data: { + body: cdef.body, + postId: post.id, + authorId: cdef.author, + isAdmin: cdef.isAdmin ?? false, + adminUserId: cdef.isAdmin ? admin.id : undefined, + replyToId: parentId, + createdAt: daysAgo(cdef.daysAgo), + updatedAt: cdef.edited ? daysAgo(cdef.edited.daysAgo) : daysAgo(cdef.daysAgo), + }, + }); + + if (cdef.edited) { + await prisma.editHistory.create({ + data: { + commentId: c.id, + editedBy: cdef.author, + previousBody: cdef.edited.prevBody, + createdAt: daysAgo(cdef.edited.daysAgo), + }, + }); + } + + for (const r of cdef.reactions ?? []) { + for (const uid of r.users) { + await prisma.reaction.create({ + data: { emoji: r.emoji, commentId: c.id, userId: uid, createdAt: daysAgo(cdef.daysAgo - 0.5) }, + }); + } + } + + for (const reply of cdef.replies ?? []) { + await createComment(reply, c.id); + } + + return c; + } + + for (const cdef of def.comments ?? []) { + await createComment(cdef); + } + + // Update lastActivityAt to most recent comment + const latestComment = def.comments?.length ? daysAgo(Math.min(...def.comments.map((c) => c.daysAgo))) : created; + await prisma.post.update({ where: { id: post.id }, data: { lastActivityAt: latestComment, updatedAt: latestComment } }); + + // Post edit history + if (def.edited) { + await prisma.editHistory.create({ + data: { + postId: post.id, + editedBy: def.author, + previousTitle: def.edited.prevTitle, + previousDescription: def.edited.prevDesc ?? undefined, + createdAt: daysAgo(def.edited.daysAgo), + }, + }); + } + + // Activity event + await prisma.activityEvent.create({ + data: { + type: "post_created", + boardId: b.id, + postId: post.id, + metadata: { title: post.title, type: post.type }, + createdAt: created, + }, + }); + + return post; + } + + console.log("Creating posts for Cloudpush..."); + + // ═══════════════════════════════════════════ + // CLOUDPUSH - File sync & cloud storage + // ═══════════════════════════════════════════ + + await createPost({ + board: "cloudpush", + type: PostType.BUG_REPORT, + title: "Files keep re-uploading after I rename them", + desc: { stepsToReproduce: "Upload a file, wait for sync, rename it locally. The old name version re-appears on the server within a few minutes.", expectedBehavior: "Renaming should just update the filename, not trigger a re-upload.", actualBehavior: "Both the old and new name exist. If I delete the old one it comes back.", environment: "macOS 14.3, Cloudpush 2.8.1" }, + author: u("Marcus"), + status: "IN_PROGRESS", + category: catSlug("bug"), + pinned: true, + tags: ["high-priority"], + votes: [ + { user: u("Lina"), importance: "critical" }, + { user: u("Tom") }, + { user: u("Priya"), importance: "important" }, + { user: anon(0) }, + { user: u("Jesse"), importance: "critical" }, + { user: u("Noor") }, + { user: u("Seb") }, + { user: u("Hana"), importance: "important" }, + { user: u("Derek") }, + { user: u("Yuki") }, + ], + statusChanges: [ + { from: "OPEN", to: "UNDER_REVIEW", daysAgo: 25 }, + { from: "UNDER_REVIEW", to: "IN_PROGRESS", reason: "Confirmed. The rename detection heuristic is comparing file hashes wrong on APFS volumes. Working on a fix.", daysAgo: 18 }, + ], + comments: [ + { body: "Same thing happening to me on Windows 11. Renamed a whole folder of project files and got duplicates of everything.", author: u("Tom"), daysAgo: 27, reactions: [{ emoji: "👍", users: [u("Marcus"), u("Priya")] }] }, + { body: "I keep getting duplicates too. I'm on a NAS and every file I rename shows up twice.", author: anon(1), daysAgo: 26 }, + { body: "This is the rename detection heuristic. It uses inode tracking on Linux/APFS but falls back to hash comparison when inodes change, and APFS reassigns inodes on rename. We have a fix in testing.", author: adminUserId, isAdmin: true, daysAgo: 22, reactions: [{ emoji: "🎉", users: [u("Marcus"), u("Tom"), u("Lina")] }] }, + { body: "Any ETA on the fix? I have a client project with 400+ files and I'm scared to rename anything right now.", author: u("Lina"), daysAgo: 19 }, + { body: "Should be in 2.8.3, targeting next week. If you want to test the beta you can grab it from the settings page under \"Release channel\".", author: adminUserId, isAdmin: true, daysAgo: 18, reactions: [{ emoji: "❤️", users: [u("Lina")] }] }, + { body: "Running the beta for 2 days now, no more ghost duplicates. Thanks!", author: u("Lina"), daysAgo: 15, reactions: [{ emoji: "🎉", users: [u("Marcus"), u("Jesse")] }] }, + ], + createdDaysAgo: 28, + }); + + await createPost({ + board: "cloudpush", + type: PostType.FEATURE_REQUEST, + title: "Let me share a folder with an expiration date", + desc: { useCase: "I share project folders with freelancers and always forget to revoke access when the contract ends. Want to set a date and have it auto-expire.", proposedSolution: "Add a \"valid until\" field when creating share links for folders.", alternativesConsidered: "I could set a calendar reminder but that defeats the purpose of the tool doing it for me." }, + author: u("Priya"), + status: "PLANNED", + category: catSlug("ui-ux"), + tags: ["v2"], + votes: [ + { user: u("Marcus"), importance: "important" }, + { user: u("Tom"), importance: "nice_to_have" }, + { user: u("Jesse") }, + { user: u("Noor"), importance: "important" }, + { user: u("Derek") }, + { user: anon(1) }, + { user: u("Fran") }, + ], + statusChanges: [ + { from: "OPEN", to: "PLANNED", reason: "Good idea, adding to the v2 sharing overhaul.", daysAgo: 10 }, + ], + comments: [ + { body: "yes!! I shared a folder with a contractor 6 months ago and just realized they still have access", author: u("Noor"), daysAgo: 18, reactions: [{ emoji: "😬", users: [u("Priya")] }] }, + { body: "Same situation here but with an ex-employee. Would be nice to see a list of all active shares and revoke from one place too.", author: anon(2), daysAgo: 16 }, + { body: "We are planning a bigger sharing rework for v2. Expiration links, password-protected folders, per-file permissions. This will be part of it.", author: adminUserId, isAdmin: true, daysAgo: 10 }, + ], + edited: { prevTitle: "Shared folder link expiration", daysAgo: 19 }, + createdDaysAgo: 20, + }); + + await createPost({ + board: "cloudpush", + type: PostType.BUG_REPORT, + title: "Sync icon stuck on \"syncing\" even though everything is up to date", + desc: { stepsToReproduce: "Open the app, let everything sync, then check the tray icon. It shows the spinning sync arrows forever.", expectedBehavior: "Should show a green checkmark after sync finishes.", actualBehavior: "Spinning arrows forever. Clicking \"sync now\" doesn't fix it. Restarting the app does, but it comes back.", environment: "Ubuntu 22.04, Cloudpush 2.7.4 AppImage" }, + author: u("Jesse"), + status: "DONE", + category: catSlug("bug"), + tags: ["quick-win"], + votes: [ + { user: u("Marcus") }, + { user: u("Seb") }, + { user: u("Derek") }, + { user: anon(2) }, + ], + statusChanges: [ + { from: "OPEN", to: "IN_PROGRESS", daysAgo: 35 }, + { from: "IN_PROGRESS", to: "DONE", reason: "Fixed in 2.8.0. The issue was a race condition in the file watcher debounce timer on Linux.", daysAgo: 30 }, + ], + comments: [ + { body: "Can confirm on Fedora 39 too. It seems to happen more when I have a lot of small files syncing.", author: u("Seb"), daysAgo: 38 }, + { body: "Fixed in 2.8.0 - the file watcher on Linux was firing duplicate events and the debounce wasn't catching them all. Let us know if you still see it.", author: adminUserId, isAdmin: true, daysAgo: 30, reactions: [{ emoji: "👍", users: [u("Jesse"), u("Seb")] }] }, + ], + createdDaysAgo: 40, + }); + + await createPost({ + board: "cloudpush", + type: PostType.FEATURE_REQUEST, + title: "Bandwidth throttle setting", + desc: { useCase: "My upload saturates my connection and makes video calls choppy. I want to cap Cloudpush to like 5 Mbps upload during work hours.", proposedSolution: "A slider or input field for max upload/download speed, maybe with a schedule so it can be unlimited at night." }, + author: u("Tom"), + status: "OPEN", + votes: [ + { user: u("Priya") }, + { user: u("Noor") }, + { user: u("Yuki") }, + ], + comments: [ + { body: "I would love a schedule option for this. Full speed at night, throttled during working hours.", author: u("Priya"), daysAgo: 12 }, + { body: "10 Mbps cap would be enough for me. My ISP gives me 25 up and Cloudpush takes all of it during initial sync.", author: anon(0), daysAgo: 10 }, + ], + edited: { prevTitle: "Upload speed limit / bandwidth cap", prevDesc: { useCase: "Cloudpush uses all my upload bandwidth. I need a way to limit it." }, daysAgo: 13 }, + createdDaysAgo: 14, + }); + + await createPost({ + board: "cloudpush", + type: PostType.BUG_REPORT, + title: "Can't upload files with # in the filename", + desc: { stepsToReproduce: "Try to upload a file named \"meeting notes #3.pdf\"", expectedBehavior: "File uploads normally", actualBehavior: "Error message: \"Invalid filename\". The # character seems to be blocked.", environment: "Web interface, Chrome 121" }, + author: anon(0), + status: "DONE", + category: catSlug("bug"), + statusChanges: [ + { from: "OPEN", to: "DONE", reason: "The web uploader was URL-encoding filenames and # was breaking the path parser. Patched.", daysAgo: 5 }, + ], + votes: [{ user: u("Tom") }, { user: u("Derek") }], + comments: [ + { body: "Also happens with & and probably other special characters", author: u("Derek"), daysAgo: 8 }, + { body: "Patched. We were passing the filename through the URL path instead of a header. Special characters should all work now.", author: adminUserId, isAdmin: true, daysAgo: 5 }, + ], + createdDaysAgo: 10, + }); + + console.log("Creating posts for Mealwise..."); + + // ═══════════════════════════════════════════ + // MEALWISE - Meal planning & recipes + // ═══════════════════════════════════════════ + + await createPost({ + board: "mealwise", + type: PostType.FEATURE_REQUEST, + title: "Shopping list should combine quantities across recipes", + desc: { useCase: "If two recipes both need onions, I want one line that says \"3 onions\" not two separate entries.", proposedSolution: "Group ingredients by name, sum up the amounts. Maybe keep the recipe source as a note so I know where each amount came from." }, + author: u("Hana"), + status: "IN_PROGRESS", + pinned: true, + tags: ["high-priority"], + votes: [ + { user: u("Marcus"), importance: "critical" }, + { user: u("Lina"), importance: "important" }, + { user: u("Tom") }, + { user: u("Priya") }, + { user: u("Noor") }, + { user: u("Seb") }, + { user: anon(0) }, + { user: u("Yuki") }, + { user: u("Fran"), importance: "critical" }, + { user: u("Derek") }, + { user: u("Jesse") }, + { user: anon(1) }, + ], + statusChanges: [ + { from: "OPEN", to: "UNDER_REVIEW", daysAgo: 22 }, + { from: "UNDER_REVIEW", to: "IN_PROGRESS", reason: "The hard part is ingredient matching - \"1 onion\" and \"1 medium yellow onion\" need to merge. Working on a fuzzy matcher.", daysAgo: 12 }, + ], + comments: [ + { body: "This is the #1 thing keeping me from using the meal planning feature. I end up rewriting the shopping list by hand anyway.", author: u("Fran"), daysAgo: 23, reactions: [{ emoji: "👍", users: [u("Hana"), u("Marcus"), u("Noor")] }] }, + { body: "Tricky part here is matching - \"2 cloves garlic\" and \"1 head of garlic\" are different things. We're working on an ingredient parser that handles unit conversions too.", author: adminUserId, isAdmin: true, daysAgo: 12, + reactions: [{ emoji: "🎉", users: [u("Hana"), u("Fran")] }], + }, + { body: "I'd rather have imperfect merging that I can fix than no merging at all. Even just matching exact strings would cover 80% of my recipes.", author: u("Lina"), daysAgo: 10 }, + { body: "Fair point. We might ship exact matching first and improve it over time.", author: adminUserId, isAdmin: true, daysAgo: 9 }, + { body: "Would the combined list let you check items off as you shop? That's the other thing that's missing.", author: anon(1), daysAgo: 7 }, + ], + createdDaysAgo: 25, + }); + + await createPost({ + board: "mealwise", + type: PostType.BUG_REPORT, + title: "Serving size multiplier breaks fractions", + desc: { stepsToReproduce: "Open any recipe, change servings from 4 to 6. Look at ingredient amounts.", expectedBehavior: "1/2 cup should become 3/4 cup", actualBehavior: "Shows \"0.75 cup\" which is technically right but nobody measures that way", environment: "iOS app 3.1.2" }, + author: u("Yuki"), + status: "PLANNED", + category: catSlug("mobile"), + votes: [ + { user: u("Hana") }, + { user: u("Fran") }, + { user: u("Noor") }, + { user: u("Priya") }, + ], + statusChanges: [ + { from: "OPEN", to: "PLANNED", reason: "Need a fraction display library. Adding to the next mobile sprint.", daysAgo: 6 }, + ], + comments: [ + { body: "It also says things like \"1.3333 tablespoons\" which... no.", author: u("Noor"), daysAgo: 11, reactions: [{ emoji: "😂", users: [u("Yuki"), u("Hana"), u("Fran")] }] }, + { body: "yeah we need proper fraction rounding. 1.33 tbsp should probably just say \"1 and 1/3 tablespoons\" or round to 1.5. Putting this on the sprint board.", author: adminUserId, isAdmin: true, daysAgo: 6 }, + ], + createdDaysAgo: 14, + }); + + await createPost({ + board: "mealwise", + type: PostType.FEATURE_REQUEST, + title: "Import recipes from a URL", + desc: { useCase: "I find recipes on food blogs and want to save them to Mealwise without retyping everything. Most recipe sites use structured data (JSON-LD) so it should be parseable.", proposedSolution: "Paste a URL, the app scrapes the recipe schema and fills in the fields." }, + author: u("Lina"), + status: "DONE", + tags: ["quick-win"], + votes: [ + { user: u("Hana") }, + { user: u("Priya") }, + { user: u("Marcus") }, + { user: u("Tom") }, + { user: u("Yuki") }, + { user: u("Fran") }, + { user: u("Jesse") }, + { user: anon(2) }, + ], + statusChanges: [ + { from: "OPEN", to: "IN_PROGRESS", daysAgo: 50 }, + { from: "IN_PROGRESS", to: "DONE", reason: "Shipped in 3.0. We parse JSON-LD, Microdata, and fall back to OpenGraph if neither is present.", daysAgo: 35 }, + ], + comments: [ + { body: "Paprika does this and it works for like 90% of sites. The ones that don't work are usually behind paywalls anyway.", author: u("Marcus"), daysAgo: 55 }, + { body: "Shipped in 3.0! Paste a URL and we'll pull the recipe automatically. Works with any site that uses JSON-LD or Microdata recipe markup, which is most of them.", author: adminUserId, isAdmin: true, daysAgo: 35, reactions: [{ emoji: "🎉", users: [u("Lina"), u("Marcus"), u("Hana"), u("Priya")] }] }, + { body: "Just tried it with a NYT Cooking link and it worked perfectly. Even got the photo.", author: u("Priya"), daysAgo: 33 }, + ], + createdDaysAgo: 60, + }); + + await createPost({ + board: "mealwise", + type: PostType.FEATURE_REQUEST, + title: "Calorie and macro tracking per meal", + desc: { useCase: "I track my macros and currently have to manually enter everything into a separate app. Would be great if Mealwise showed protein/carbs/fat per serving.", proposedSolution: "Pull nutrition data from a database (USDA maybe?) and calculate per-recipe." }, + author: u("Jesse"), + status: "OPEN", + votes: [ + { user: u("Seb") }, + { user: u("Hana") }, + { user: u("Derek") }, + ], + comments: [ + { + body: "I'd use this but please make it optional. I don't want calorie counts in my face every time I open a recipe.", + author: u("Hana"), daysAgo: 4, + edited: { prevBody: "I'd use this but please don't make it the default. I don't want calorie counts everywhere.", daysAgo: 4 }, + reactions: [{ emoji: "👍", users: [u("Noor"), u("Fran")] }], + replies: [ + { body: "Agreed. Maybe a toggle in settings, off by default?", author: u("Noor"), daysAgo: 3 }, + ], + }, + ], + createdDaysAgo: 7, + }); + + await createPost({ + board: "mealwise", + type: PostType.BUG_REPORT, + title: "Timer alarm doesn't play when phone is on silent", + desc: { stepsToReproduce: "Set a cooking timer, put phone on silent mode, wait for timer to finish.", expectedBehavior: "Some kind of notification, vibration, anything.", actualBehavior: "Nothing happens. I burned the garlic bread.", environment: "iPhone 15, iOS 17.2, Mealwise 3.1.0" }, + author: u("Tom"), + status: "OPEN", + category: catSlug("mobile"), + tags: ["needs-repro"], + votes: [{ user: u("Priya") }, { user: u("Noor") }], + comments: [ + { body: "Pretty sure this is an iOS restriction? Apps can't override silent mode unless they use a specific audio category. Maybe use the \"alarm\" audio session?", author: u("Seb"), daysAgo: 2 }, + { body: "Happened to me with cookies in the oven. A vibration at least would help.", author: anon(0), daysAgo: 1 }, + ], + createdDaysAgo: 4, + }); + + console.log("Creating posts for Trackpad..."); + + // ═══════════════════════════════════════════ + // TRACKPAD - Project management + // ═══════════════════════════════════════════ + + await createPost({ + board: "trackpad", + type: PostType.FEATURE_REQUEST, + title: "Recurring tasks", + desc: { useCase: "I have weekly standups, monthly reports, and daily checks that I create manually every time. Need a way to set up a task once and have it repeat.", proposedSolution: "A \"repeat\" option on tasks: daily, weekly, monthly, custom. When you complete a recurring task it auto-creates the next instance." }, + author: u("Marcus"), + status: "IN_PROGRESS", + pinned: true, + tags: ["high-priority", "v2"], + votes: [ + { user: u("Lina"), importance: "critical" }, + { user: u("Tom"), importance: "critical" }, + { user: u("Priya"), importance: "important" }, + { user: u("Jesse") }, + { user: u("Noor") }, + { user: u("Seb"), importance: "important" }, + { user: u("Hana") }, + { user: u("Derek"), importance: "critical" }, + { user: u("Fran") }, + { user: anon(0) }, + { user: anon(1) }, + { user: anon(2) }, + { user: u("Yuki") }, + ], + statusChanges: [ + { from: "OPEN", to: "PLANNED", daysAgo: 40 }, + { from: "PLANNED", to: "IN_PROGRESS", reason: "Started work on the recurrence engine. First version will support daily/weekly/monthly/yearly.", daysAgo: 15 }, + ], + comments: [ + { body: "This is the biggest missing feature for me. Every other PM tool has this.", author: u("Derek"), daysAgo: 43 }, + { body: "What should happen when a recurring task is overdue? Should the next one still generate? Or should it wait until the current one is done?", author: adminUserId, isAdmin: true, daysAgo: 38, + replies: [ + { body: "Generate it anyway. If I miss one weekly report I still need to do this week's.", author: u("Marcus"), daysAgo: 37 }, + { body: "Maybe make it configurable? Some tasks make sense to skip if overdue, others don't.", author: u("Lina"), daysAgo: 37 }, + ], + }, + { body: "Started building this. First version will support daily/weekly/monthly/yearly with a custom interval option (e.g. every 3 days). Complex rules like \"second Tuesday of each month\" will come later.", author: adminUserId, isAdmin: true, daysAgo: 15, reactions: [{ emoji: "🎉", users: [u("Marcus"), u("Derek"), u("Lina"), u("Tom")] }] }, + { body: "Can't wait. I've been creating weekly tasks manually for months. Even just weekly repeat would save me 10 minutes every Monday.", author: anon(2), daysAgo: 12 }, + ], + createdDaysAgo: 45, + }); + + await createPost({ + board: "trackpad", + type: PostType.BUG_REPORT, + title: "Drag and drop breaks if you scroll while dragging", + desc: { stepsToReproduce: "Have a board with enough cards to scroll. Start dragging a card, then scroll the board (mouse wheel or trackpad) while still holding the card.", expectedBehavior: "Card follows the cursor and drops where I release", actualBehavior: "Card snaps back to its original position. Sometimes it moves to a completely wrong column.", environment: "Chrome 122, Windows 11. Also happens on Firefox." }, + author: u("Seb"), + status: "IN_PROGRESS", + category: catSlug("bug"), + votes: [ + { user: u("Marcus") }, + { user: u("Tom") }, + { user: u("Derek") }, + { user: u("Lina") }, + { user: u("Priya") }, + ], + statusChanges: [ + { from: "OPEN", to: "IN_PROGRESS", reason: "Reproducing. The drag library we use has a known issue with scroll containers.", daysAgo: 5 }, + ], + comments: [ + { body: "Happens on Mac too. The card teleports to a random spot if you scroll fast.", author: u("Marcus"), daysAgo: 8, + reactions: [{ emoji: "👍", users: [u("Seb"), u("Tom")] }], + edited: { prevBody: "Happens on Mac too. The card teleports to a random spot.", daysAgo: 8 }, + }, + { body: "We're using dnd-kit and this is a known upstream issue with scroll containers. Looking at either patching it or switching to pragmatic-drag-and-drop.", author: adminUserId, isAdmin: true, daysAgo: 5 }, + ], + createdDaysAgo: 10, + }); + + await createPost({ + board: "trackpad", + type: PostType.FEATURE_REQUEST, + title: "Subtasks / checklist inside a task", + desc: { useCase: "Some tasks have multiple steps. Right now I put them in the description as a markdown checklist but that's just text, I can't track progress or assign individual items.", proposedSolution: "Actual subtasks that show completion percentage on the parent task card." }, + author: u("Lina"), + status: "PLANNED", + votes: [ + { user: u("Marcus") }, + { user: u("Tom") }, + { user: u("Priya") }, + { user: u("Hana") }, + { user: u("Fran") }, + { user: anon(0) }, + ], + statusChanges: [ + { from: "OPEN", to: "PLANNED", reason: "Planned for the task detail rework.", daysAgo: 8 }, + ], + comments: [ + { body: "Even just checkboxes that save state would be a huge improvement. Doesn't need to be full subtasks with assignees on day one.", author: u("Tom"), daysAgo: 16 }, + { body: "Agreed. We'll start with checkable items and add assignee/due date per subtask later.", author: adminUserId, isAdmin: true, daysAgo: 8 }, + ], + createdDaysAgo: 20, + }); + + await createPost({ + board: "trackpad", + type: PostType.FEATURE_REQUEST, + title: "Slack notifications when a task is assigned to me", + desc: { useCase: "I live in Slack. When someone assigns me a task I won't see it until I open Trackpad, which might be hours later.", proposedSolution: "Slack integration that sends a DM when you get assigned a task, when a due date is approaching, or when someone comments on your task." }, + author: u("Noor"), + status: "OPEN", + category: catSlug("integrations"), + votes: [ + { user: u("Marcus") }, + { user: u("Jesse") }, + { user: u("Derek") }, + { user: u("Seb") }, + ], + comments: [ + { body: "Discord too please? Not everyone uses Slack.", author: u("Jesse"), daysAgo: 5, reactions: [{ emoji: "👍", users: [u("Seb")] }] }, + { body: "Even just email notifications for assignments would go a long way. I don't need a full integration.", author: anon(1), daysAgo: 4 }, + ], + createdDaysAgo: 8, + }); + + await createPost({ + board: "trackpad", + type: PostType.BUG_REPORT, + title: "Due date filter shows tasks from other projects", + desc: { stepsToReproduce: "Open project A, click \"Due this week\" filter", expectedBehavior: "Only tasks from project A due this week", actualBehavior: "Shows tasks from all projects due this week", environment: "Web app, any browser" }, + author: u("Priya"), + status: "DONE", + category: catSlug("bug"), + statusChanges: [ + { from: "OPEN", to: "DONE", reason: "Fixed. The filter query wasn't scoped to the project.", daysAgo: 12 }, + ], + votes: [{ user: u("Lina") }, { user: u("Tom") }], + comments: [ + { body: "Yep. Noticed this too. The \"Overdue\" filter also has the same problem.", author: u("Lina"), daysAgo: 15 }, + { body: "Fixed both filters. The WHERE clause was missing the project_id condition. Embarrassing but at least it was a one-liner.", author: adminUserId, isAdmin: true, daysAgo: 12, reactions: [{ emoji: "😂", users: [u("Priya"), u("Lina")] }] }, + ], + createdDaysAgo: 18, + }); + + console.log("Creating posts for Pocketcast..."); + + // ═══════════════════════════════════════════ + // POCKETCAST - Podcast player + // ═══════════════════════════════════════════ + + await createPost({ + board: "pocketcast", + type: PostType.FEATURE_REQUEST, + title: "Variable playback speed per show", + desc: { useCase: "I listen to conversational podcasts at 1.5x but narrative/storytelling ones at 1x. Right now I have to change the speed every time I switch shows.", proposedSolution: "Save the playback speed per podcast, not globally." }, + author: u("Hana"), + status: "DONE", + pinned: true, + votes: [ + { user: u("Marcus") }, + { user: u("Tom") }, + { user: u("Priya") }, + { user: u("Jesse") }, + { user: u("Noor") }, + { user: u("Seb") }, + { user: u("Derek") }, + { user: u("Yuki") }, + { user: u("Fran") }, + { user: u("Lina") }, + ], + statusChanges: [ + { from: "OPEN", to: "IN_PROGRESS", daysAgo: 30 }, + { from: "IN_PROGRESS", to: "DONE", reason: "Shipped in 4.2. Long press the speed button to save per-show.", daysAgo: 20 }, + ], + comments: [ + { body: "Please. I listen to like 15 different shows and the speed juggling is driving me crazy.", author: u("Jesse"), daysAgo: 40 }, + { body: "This is coming in 4.2. You can long-press the speed button to set a per-show default. The global speed still applies to any show that doesn't have a custom one.", author: adminUserId, isAdmin: true, daysAgo: 25, reactions: [{ emoji: "🎉", users: [u("Hana"), u("Jesse"), u("Marcus"), u("Fran")] }] }, + { body: "4.2 is out, per-show speed is there. Works exactly how I wanted it to.", author: u("Hana"), daysAgo: 18, reactions: [{ emoji: "❤️", users: [u("Jesse")] }] }, + ], + createdDaysAgo: 45, + }); + + await createPost({ + board: "pocketcast", + type: PostType.BUG_REPORT, + title: "Downloads fail silently on cellular", + desc: { stepsToReproduce: "Enable \"download on cellular\", queue an episode for download while on LTE.", expectedBehavior: "Episode downloads", actualBehavior: "Download icon spins for a second then disappears. No error message. Episode isn't downloaded. Works fine on wifi.", environment: "Android 14, Pixel 8, Pocketcast 4.1.3" }, + author: u("Derek"), + status: "UNDER_REVIEW", + category: catSlug("mobile"), + tags: ["needs-repro"], + votes: [ + { user: u("Tom") }, + { user: anon(0) }, + { user: u("Seb") }, + ], + comments: [ + { body: "Having the same issue on a Samsung S24. Thought it was my carrier throttling but it happens on different networks too.", author: anon(0), daysAgo: 5 }, + { body: "Can you check Settings > Storage and see how much space is free? Some Android vendors kill background downloads aggressively when storage is below ~1GB. We're looking into whether we need to use WorkManager instead of our current download service.", author: adminUserId, isAdmin: true, daysAgo: 3 }, + { body: "130GB free. Definitely not a space issue.", author: u("Derek"), daysAgo: 2, reactions: [{ emoji: "👍", users: [u("Seb")] }] }, + ], + createdDaysAgo: 8, + }); + + await createPost({ + board: "pocketcast", + type: PostType.FEATURE_REQUEST, + title: "Sleep timer that fades out instead of hard stopping", + desc: { useCase: "When the sleep timer runs out, the audio just cuts off abruptly and it's jarring. Would be nicer if it faded out over 30 seconds or so.", proposedSolution: "Gradual volume fade in the last 30-60 seconds before the timer ends." }, + author: u("Yuki"), + status: "OPEN", + votes: [ + { user: u("Hana") }, + { user: u("Fran") }, + { user: u("Noor") }, + { user: u("Lina") }, + { user: u("Marcus") }, + ], + comments: [ + { body: "Overcast does this and it's so much nicer. I always wake up when my current app just stops mid-sentence.", author: u("Noor"), daysAgo: 6 }, + { body: "A \"finish current chapter\" option for audiobooks would be great too. Some podcast apps treat chapters as segments.", author: u("Fran"), daysAgo: 4 }, + { body: "30 second fade would be perfect. I fall asleep to podcasts every night and the abrupt stop always jolts me awake.", author: anon(0), daysAgo: 3 }, + ], + edited: { prevTitle: "Sleep timer should fade audio out gradually", daysAgo: 8 }, + createdDaysAgo: 9, + }); + + await createPost({ + board: "pocketcast", + type: PostType.FEATURE_REQUEST, + title: "CarPlay layout is too cramped", + desc: { useCase: "The CarPlay screen shows tiny text and too many buttons. Hard to use while driving (which is the whole point).", proposedSolution: "Bigger text, fewer buttons, maybe a simplified layout for CarPlay specifically. The artwork is taking up half the screen." }, + author: u("Tom"), + status: "OPEN", + category: catSlug("mobile"), + votes: [ + { user: u("Derek") }, + { user: u("Marcus") }, + ], + comments: [], + createdDaysAgo: 3, + }); + + await createPost({ + board: "pocketcast", + type: PostType.BUG_REPORT, + title: "\"Mark as played\" doesn't sync between devices", + desc: { stepsToReproduce: "Mark an episode as played on phone. Open the app on tablet. Episode still shows as unplayed.", expectedBehavior: "Played status syncs across devices", actualBehavior: "Only syncs after force-closing and reopening the app on the other device. Pull to refresh doesn't do it.", environment: "iPhone 15 Pro + iPad Air M2, both on latest app version" }, + author: u("Fran"), + status: "IN_PROGRESS", + category: catSlug("bug"), + votes: [ + { user: u("Hana") }, + { user: u("Jesse") }, + { user: anon(1) }, + ], + statusChanges: [ + { from: "OPEN", to: "IN_PROGRESS", reason: "The sync interval for playback state is 15 minutes. Looking at making it push-based instead of polling.", daysAgo: 4 }, + ], + comments: [ + { body: "Same issue with playback position. I'll be 30 minutes into an episode on my phone and my iPad wants to start from 0.", author: u("Hana"), daysAgo: 8 }, + { body: "I can confirm this on two Android devices. Marking played on my phone doesn't show up on my tablet until I kill the app.", author: anon(2), daysAgo: 6 }, + ], + createdDaysAgo: 12, + }); + + console.log("Creating posts for Greenline..."); + + // ═══════════════════════════════════════════ + // GREENLINE - Public transit + // ═══════════════════════════════════════════ + + await createPost({ + board: "greenline", + type: PostType.FEATURE_REQUEST, + title: "Show how full the bus/train is", + desc: { useCase: "During rush hour some buses are packed and I'd rather wait for the next one. Transit agencies sometimes publish occupancy data via GTFS-RT.", proposedSolution: "If the transit agency provides real-time occupancy data, show it on the route view. Even a simple empty/half/full indicator would help." }, + author: u("Noor"), + status: "PLANNED", + pinned: true, + tags: ["v2"], + votes: [ + { user: u("Marcus"), importance: "important" }, + { user: u("Lina") }, + { user: u("Tom") }, + { user: u("Priya"), importance: "nice_to_have" }, + { user: u("Jesse") }, + { user: u("Hana") }, + { user: u("Seb") }, + { user: u("Derek") }, + { user: u("Yuki") }, + { user: anon(0) }, + { user: anon(1) }, + { user: anon(2) }, + { user: u("Fran") }, + ], + statusChanges: [ + { from: "OPEN", to: "PLANNED", reason: "We've started collecting GTFS-RT OccupancyStatus where available. Will add to the UI once we have enough coverage.", daysAgo: 10 }, + ], + comments: [ + { body: "NYC MTA publishes this data for subways already. Google Maps shows it sometimes. Would be great to have it here too.", author: u("Marcus"), daysAgo: 28 }, + { body: "We've been looking at GTFS-RT OccupancyStatus feeds. About 40% of the agencies we support publish it. Plan is to show a simple icon (empty seat, half-full, standing room, packed) when data is available.", author: adminUserId, isAdmin: true, daysAgo: 10, reactions: [{ emoji: "🎉", users: [u("Noor"), u("Marcus")] }] }, + { body: "Even if it's not available for every city, showing it where you can would be great. Some data is better than none.", author: u("Lina"), daysAgo: 8 }, + ], + createdDaysAgo: 30, + }); + + await createPost({ + board: "greenline", + type: PostType.BUG_REPORT, + title: "Wrong arrival time for route 47", + desc: { stepsToReproduce: "Search for route 47 at Oak Street station, check the next arrival.", expectedBehavior: "Shows real-time arrival estimate", actualBehavior: "Shows the schedule time even when the bus is running late. The real-time feed works for other routes.", environment: "Web app and Android app" }, + author: u("Seb"), + status: "DONE", + category: catSlug("bug"), + statusChanges: [ + { from: "OPEN", to: "DONE", reason: "Route 47 was mapped to the wrong GTFS trip_id in our database. Fixed the mapping.", daysAgo: 14 }, + ], + votes: [{ user: u("Noor") }, { user: u("Derek") }], + comments: [ + { body: "Route 47 was mapped to the wrong trip_id. We fixed the route mapping. Real-time data should show up correctly now. Let us know if you spot other routes with this problem.", author: adminUserId, isAdmin: true, daysAgo: 14 }, + { body: "Looking good now, thanks", author: u("Seb"), daysAgo: 13 }, + ], + createdDaysAgo: 20, + }); + + await createPost({ + board: "greenline", + type: PostType.FEATURE_REQUEST, + title: "Favorite routes on the home screen", + desc: { useCase: "I take the same 2 routes every day. Don't want to search for them every morning. Let me pin them so they show up front and center when I open the app.", proposedSolution: "A \"favorites\" section at the top of the home screen with next arrival times for pinned routes/stops." }, + author: u("Priya"), + status: "DONE", + votes: [ + { user: u("Noor") }, + { user: u("Marcus") }, + { user: u("Seb") }, + { user: u("Derek") }, + { user: u("Hana") }, + { user: u("Tom") }, + ], + statusChanges: [ + { from: "OPEN", to: "IN_PROGRESS", daysAgo: 40 }, + { from: "IN_PROGRESS", to: "DONE", reason: "Shipped. Star any stop to add it to your home screen.", daysAgo: 32 }, + ], + comments: [ + { body: "Shipped! You can now star any stop/route and it'll appear on your home screen with live arrival times. Long press to reorder.", author: adminUserId, isAdmin: true, daysAgo: 32, reactions: [{ emoji: "🎉", users: [u("Priya"), u("Noor"), u("Marcus")] }] }, + ], + createdDaysAgo: 50, + }); + + await createPost({ + board: "greenline", + type: PostType.FEATURE_REQUEST, + title: "Offline timetable for when I'm underground", + desc: { useCase: "The subway stations near me have no cell signal. Would be helpful to have a cached version of the schedule so I at least know the schedule times if not the live ones.", proposedSolution: "Download and cache the static GTFS schedule for favorited routes." }, + author: u("Marcus"), + status: "OPEN", + votes: [ + { user: u("Noor") }, + { user: u("Seb") }, + { user: u("Lina") }, + { user: u("Fran") }, + ], + comments: [ + { body: "Big yes. I moved to a city with underground stations and the app is useless down there. Even just a PDF of the timetable would be better than nothing.", author: u("Fran"), daysAgo: 5 }, + { body: "My commute goes through a tunnel and I always end up guessing when the next train is. Please do this.", author: anon(2), daysAgo: 3 }, + ], + createdDaysAgo: 8, + }); + + await createPost({ + board: "greenline", + type: PostType.BUG_REPORT, + title: "App crashes when switching to dark mode on Android", + desc: { stepsToReproduce: "Open settings, toggle dark mode, app crashes immediately.", expectedBehavior: "Theme changes without crashing", actualBehavior: "App force-closes. Happens every time. If I set my system to dark mode it works fine though, only the in-app toggle crashes.", environment: "Samsung Galaxy A54, Android 14, Greenline 1.9.2" }, + author: anon(2), + status: "DONE", + category: catSlug("mobile"), + statusChanges: [ + { from: "OPEN", to: "DONE", reason: "The activity was recreating before the theme value was persisted. Fixed.", daysAgo: 2 }, + ], + votes: [{ user: u("Derek") }], + comments: [ + { body: "Happening on my Pixel 7 too. Stock Android 14. Clearing app cache doesn't help.", author: anon(1), daysAgo: 4 }, + { body: "Quick fix on this one - the theme value was being applied before it was saved to SharedPreferences, so on activity recreation it loaded the old value and looped. Patched in 1.9.3, rolling out now.", author: adminUserId, isAdmin: true, daysAgo: 2 }, + ], + createdDaysAgo: 5, + }); + + console.log("Creating posts for Inkwell..."); + + // ═══════════════════════════════════════════ + // INKWELL - Writing & note-taking + // ═══════════════════════════════════════════ + + await createPost({ + board: "inkwell", + type: PostType.FEATURE_REQUEST, + title: "Backlinks / bidirectional links between notes", + desc: { useCase: "When I link to note B from note A, I want note B to automatically show a \"referenced by note A\" section. Like Obsidian or Roam do it.", proposedSolution: "Parse [[wiki-style links]] in note content and maintain a backlink index." }, + author: u("Fran"), + status: "IN_PROGRESS", + pinned: true, + tags: ["high-priority", "v2"], + votes: [ + { user: u("Marcus"), importance: "critical" }, + { user: u("Lina"), importance: "critical" }, + { user: u("Hana"), importance: "important" }, + { user: u("Jesse") }, + { user: u("Noor") }, + { user: u("Yuki"), importance: "important" }, + { user: u("Tom") }, + { user: u("Priya") }, + { user: u("Derek") }, + { user: u("Seb") }, + { user: anon(0) }, + { user: anon(1) }, + ], + statusChanges: [ + { from: "OPEN", to: "PLANNED", daysAgo: 50 }, + { from: "PLANNED", to: "IN_PROGRESS", reason: "Started on the link parser and index. This is the biggest feature of the v2 rewrite.", daysAgo: 20 }, + ], + comments: [ + { body: "This is the one feature keeping me on Obsidian. If Inkwell gets this right with a clean UI I would switch immediately.", author: u("Marcus"), daysAgo: 55 }, + { body: "same. I actually like Inkwell's editor better than Obsidian's but the lack of backlinks is a dealbreaker", author: u("Lina"), daysAgo: 53, reactions: [{ emoji: "👍", users: [u("Marcus"), u("Fran"), u("Hana")] }] }, + { body: "We hear you. Backlinks are the core feature of the v2 editor rewrite. The [[link]] syntax will autocomplete note titles, and every note will have a \"linked mentions\" panel at the bottom. Working on it now.", author: adminUserId, isAdmin: true, daysAgo: 20, reactions: [{ emoji: "🎉", users: [u("Fran"), u("Marcus"), u("Lina"), u("Hana"), u("Yuki")] }] }, + { body: "Will it support aliases? Like if I link to [[JS]] I want it to resolve to my \"JavaScript\" note.", author: u("Yuki"), daysAgo: 15 }, + { body: "Yep, aliases are planned. You'll be able to set them in note metadata.", author: adminUserId, isAdmin: true, daysAgo: 14 }, + { body: "Really looking forward to this. I have about 500 notes and finding connections between them manually is impossible.", author: anon(0), daysAgo: 10 }, + ], + createdDaysAgo: 60, + }); + + await createPost({ + board: "inkwell", + type: PostType.BUG_REPORT, + title: "Pasting from Google Docs adds invisible formatting", + desc: { stepsToReproduce: "Copy text from a Google Doc, paste into Inkwell editor.", expectedBehavior: "Clean text with basic formatting (bold, italic, links)", actualBehavior: "Adds a bunch of hidden span elements with Google's inline styles. The note looks normal but the markdown is full of garbage HTML.", environment: "Chrome, macOS" }, + author: u("Lina"), + status: "DONE", + category: catSlug("bug"), + votes: [ + { user: u("Fran") }, + { user: u("Tom") }, + { user: u("Hana") }, + { user: u("Priya") }, + ], + statusChanges: [ + { from: "OPEN", to: "IN_PROGRESS", daysAgo: 18 }, + { from: "IN_PROGRESS", to: "DONE", reason: "Added an HTML sanitizer to the paste handler. It strips everything except basic formatting tags.", daysAgo: 12 }, + ], + comments: [ + { body: "I think this happens with Word too. Anything that puts styled HTML on the clipboard.", author: u("Tom"), daysAgo: 20 }, + { body: "Fixed. The paste handler now runs clipboard HTML through a sanitizer that only keeps p, strong, em, a, ul, ol, li, code, and br. Everything else gets stripped. Let me know if you notice any formatting that should be preserved but isn't.", author: adminUserId, isAdmin: true, daysAgo: 12, reactions: [{ emoji: "🎉", users: [u("Lina"), u("Tom")] }] }, + { body: "Tested with a big Google Doc full of headers, tables, and footnotes. Everything came through clean. Nice fix.", author: u("Lina"), daysAgo: 10 }, + ], + createdDaysAgo: 22, + }); + + await createPost({ + board: "inkwell", + type: PostType.FEATURE_REQUEST, + title: "Publish a note as a public page", + desc: { useCase: "Sometimes I write something I want to share with people who don't use Inkwell. A blog post draft, meeting notes, a how-to guide.", proposedSolution: "A \"publish\" button that generates a public URL. Maybe with a custom subdomain like username.inkwell.app/note-title" }, + author: u("Hana"), + status: "PLANNED", + votes: [ + { user: u("Fran") }, + { user: u("Marcus") }, + { user: u("Jesse") }, + { user: u("Yuki") }, + { user: u("Noor") }, + ], + statusChanges: [ + { from: "OPEN", to: "PLANNED", reason: "On the roadmap for after backlinks ship.", daysAgo: 8 }, + ], + comments: [ + { body: "Notion does this and honestly it's the reason half my team still uses Notion for docs we share externally.", author: u("Jesse"), daysAgo: 11 }, + { body: "Would be great if published notes could have their own theme/CSS. I want my public notes to look like my personal site, not like the Inkwell app.", author: u("Yuki"), daysAgo: 8 }, + { body: "Even without custom themes, just a clean read-only page with a shareable URL would be enough for me.", author: anon(1), daysAgo: 6 }, + ], + edited: { prevTitle: "Share notes as public web pages", daysAgo: 14 }, + createdDaysAgo: 15, + }); + + await createPost({ + board: "inkwell", + type: PostType.BUG_REPORT, + title: "Cursor jumps to end of line when bold text is near a link", + desc: { stepsToReproduce: "Type a sentence with **bold text** followed by a [link](url). Place cursor between the bold and the link. Type something.", expectedBehavior: "Characters appear where the cursor is", actualBehavior: "Cursor jumps to the end of the line after every keystroke. Have to click back to position each time." }, + author: u("Marcus"), + status: "OPEN", + category: catSlug("bug"), + tags: ["needs-repro"], + votes: [ + { user: u("Lina") }, + { user: u("Fran") }, + ], + comments: [ + { + body: "I think this only happens when the bold section ends right where the link starts, with no space between them. Adding a space fixes it for me but that's obviously a workaround.", + author: u("Fran"), daysAgo: 2, + edited: { prevBody: "I think this only happens when bold and link are adjacent. Adding a space between them fixes it.", daysAgo: 2 }, + }, + ], + createdDaysAgo: 5, + }); + + await createPost({ + board: "inkwell", + type: PostType.FEATURE_REQUEST, + title: "Vim keybindings option", + desc: { useCase: "I use vim motions in every other editor. My muscle memory makes me type :w and jk constantly in Inkwell.", proposedSolution: "A toggle in settings for vim mode, like VS Code and Obsidian have." }, + author: u("Jesse"), + status: "DECLINED", + statusReason: "We get this request a lot but maintaining a vim layer is a huge ongoing effort. We'd rather focus on making the default editing experience better. CodeMirror (which we use) has a vim extension if you want to build a browser plugin for it.", + tags: ["wontfix"], + votes: [ + { user: u("Seb") }, + { user: u("Yuki") }, + ], + statusChanges: [ + { from: "OPEN", to: "DECLINED", reason: "We get this request a lot but maintaining a vim layer is a huge ongoing effort. We'd rather focus on making the default editing experience better. CodeMirror (which we use) has a vim extension if you want to build a browser plugin for it.", daysAgo: 25 }, + ], + comments: [ + { body: "Understandable tbh. Vim mode in other apps is usually half-baked anyway.", author: u("Seb"), daysAgo: 24, reactions: [{ emoji: "👍", users: [u("Jesse")] }] }, + ], + createdDaysAgo: 30, + }); + + console.log("Creating changelog entries..."); + + // ═══════════════════════════════════════════ + // CHANGELOG ENTRIES + // ═══════════════════════════════════════════ + + await prisma.changelogEntry.createMany({ + data: [ + { + title: "2.8.3 - Rename detection fix", + body: "Fixed the file rename detection on macOS APFS and Windows NTFS volumes. Files should no longer re-upload when renamed. Also fixed an issue where the tray icon could get stuck in the syncing state on Linux.\n\nOther changes:\n- Reduced memory usage during large folder scans by about 30%\n- Fixed a crash when the sync target folder is on a network drive that disconnects mid-sync", + boardId: board("cloudpush").id, + publishedAt: daysAgo(15), + createdAt: daysAgo(15), + }, + { + title: "2.8.0 - Selective sync", + body: "You can now choose which folders to sync instead of syncing everything. Open the tray menu, pick \"Selective sync\", and uncheck whatever you want to skip. Changes apply after the next full scan (usually under a minute).", + boardId: board("cloudpush").id, + publishedAt: daysAgo(40), + createdAt: daysAgo(40), + }, + { + title: "3.1 - Recipe URL import", + body: "You can now import recipes by pasting a URL from any site that uses JSON-LD or Microdata recipe markup (which is most food blogs). The importer pulls in the title, ingredients, steps, servings, prep time, and photo.\n\nFixes:\n- Fixed the shopping list not updating after removing a recipe from the meal plan\n- The \"random recipe\" button no longer suggests the same recipe twice in a row", + boardId: board("mealwise").id, + publishedAt: daysAgo(35), + createdAt: daysAgo(35), + }, + { + title: "1.4 - Board view improvements", + body: "Drag and drop on the kanban board is now smoother, especially when scrolling. Switched from dnd-kit to pragmatic-drag-and-drop which handles scroll containers better.\n\nAlso new: you can now collapse columns on the board view to save space. Right-click a column header to collapse it. Collapsed columns show the card count as a badge.", + boardId: board("trackpad").id, + publishedAt: daysAgo(8), + createdAt: daysAgo(8), + }, + { + title: "1.3.2 - Subtask ordering fix", + body: "Fixed a bug where reordering subtasks within a task would sometimes put them in the wrong position. This happened when a task had more than 8 subtasks. Also fixed the keyboard shortcut for completing a subtask (Ctrl+Enter) not working on Firefox.", + boardId: board("trackpad").id, + publishedAt: daysAgo(22), + createdAt: daysAgo(22), + }, + { + title: "4.2 - Per-show playback speed", + body: "Long-press the speed button to save a custom playback speed for the current show. The global speed still applies to any show without a custom setting.\n\nAlso in this release:\n- Fixed audio ducking not restoring volume after navigation announcements on Android Auto\n- Added skip silence sensitivity setting (low/medium/high)\n- Reduced battery drain from background feed refresh by about 20%", + boardId: board("pocketcast").id, + publishedAt: daysAgo(20), + createdAt: daysAgo(20), + }, + { + title: "1.9.3 - Dark mode crash fix", + body: "Fixed a crash when toggling dark mode from the in-app settings on some Android devices. Also fixed route 47 showing schedule times instead of real-time arrivals.\n\nNew: you can now star stops to add them to your home screen with live departure times.", + boardId: board("greenline").id, + publishedAt: daysAgo(2), + createdAt: daysAgo(2), + }, + { + title: "2.0 beta - Backlinks preview", + body: "The v2 editor beta is available for testing. The big new feature is [[wiki-style links]] with backlinks. Every note now has a \"linked mentions\" panel that shows which other notes reference it.\n\nThis is an early beta. Known issues:\n- Link autocomplete is slow on vaults with 1000+ notes (we're working on indexing)\n- Renaming a note doesn't update links pointing to it yet\n- The backlink panel doesn't show context (just the note title for now)\n\nTo try it: Settings > Editor > Enable v2 editor beta", + boardId: board("inkwell").id, + publishedAt: daysAgo(5), + createdAt: daysAgo(5), + }, + { + title: "1.8 - Offline schedules", + body: "Schedules for your saved routes are now cached for offline use. If you lose signal underground or in a dead zone, the app will show the static timetable instead of a loading spinner. Real-time data takes over again once you reconnect.", + boardId: board("greenline").id, + publishedAt: daysAgo(28), + createdAt: daysAgo(28), + }, + ], + }); + + console.log("Creating notifications..."); + + // Sample notifications for various users + const notifData = [ + { type: "status_changed", title: "Status updated", body: "\"Files keep re-uploading after I rename them\" is now In Progress", userId: u("Marcus") }, + { type: "comment_reply", title: "New reply", body: "Admin replied to your comment on \"Shopping list should combine quantities\"", userId: u("Fran") }, + { type: "status_changed", title: "Status updated", body: "\"Variable playback speed per show\" is now Done", userId: u("Hana") }, + { type: "status_changed", title: "Status updated", body: "\"Recurring tasks\" is now In Progress", userId: u("Derek") }, + { type: "comment_reply", title: "New reply", body: "Lina replied to the discussion on \"Backlinks / bidirectional links\"", userId: u("Marcus") }, + ]; + + for (const n of notifData) { + await prisma.notification.create({ + data: { + type: n.type, + title: n.title, + body: n.body, + userId: n.userId, + createdAt: hoursAgo(Math.random() * 72), + }, + }); + } + + // Create activity events for status changes and comments + for (const b of boards) { + const posts = await prisma.post.findMany({ where: { boardId: b.id }, select: { id: true, title: true, status: true } }); + for (const p of posts) { + if (p.status !== "OPEN") { + await prisma.activityEvent.create({ + data: { + type: "status_changed", + boardId: b.id, + postId: p.id, + metadata: { title: p.title, status: p.status }, + createdAt: daysAgo(Math.random() * 30), + }, + }); + } + } + } + + console.log("Done! Created 6 boards with posts, comments, votes, tags, changelog, and notifications."); +} + +main() + .catch((e) => { console.error(e); process.exit(1); }) + .finally(() => prisma.$disconnect()); diff --git a/packages/api/src/cli/create-admin.ts b/packages/api/src/cli/create-admin.ts index 703daf9..67ca739 100644 --- a/packages/api/src/cli/create-admin.ts +++ b/packages/api/src/cli/create-admin.ts @@ -73,7 +73,7 @@ async function main() { const hash = await bcrypt.hash(password, 12); const admin = await prisma.adminUser.create({ - data: { email, passwordHash: hash }, + data: { email, passwordHash: hash, role: "SUPER_ADMIN" }, }); console.log(`Admin created: ${admin.email} (${admin.id})`); diff --git a/packages/api/src/config.ts b/packages/api/src/config.ts index 3ce5fa2..9a0aef9 100644 --- a/packages/api/src/config.ts +++ b/packages/api/src/config.ts @@ -3,10 +3,11 @@ import { z } from "zod"; const schema = z.object({ DATABASE_URL: z.string(), APP_MASTER_KEY: z.string().regex(/^[0-9a-fA-F]{64}$/, "Must be hex-encoded 256-bit key"), - APP_BLIND_INDEX_KEY: z.string().regex(/^[0-9a-fA-F]+$/, "Must be hex-encoded"), - TOKEN_SECRET: z.string(), - JWT_SECRET: z.string(), - ALTCHA_HMAC_KEY: z.string(), + APP_MASTER_KEY_PREVIOUS: z.string().regex(/^[0-9a-fA-F]{64}$/).optional(), + APP_BLIND_INDEX_KEY: z.string().regex(/^[0-9a-fA-F]{32,}$/, "Must be hex-encoded, at least 128 bits"), + TOKEN_SECRET: z.string().min(32), + JWT_SECRET: z.string().min(32), + ALTCHA_HMAC_KEY: z.string().min(32, "ALTCHA HMAC key must be at least 32 characters"), WEBAUTHN_RP_NAME: z.string().default("Echoboard"), WEBAUTHN_RP_ID: z.string(), @@ -38,4 +39,7 @@ if (!parsed.success) { export const config = parsed.data; export const masterKey = Buffer.from(config.APP_MASTER_KEY, "hex"); +export const previousMasterKey = config.APP_MASTER_KEY_PREVIOUS + ? Buffer.from(config.APP_MASTER_KEY_PREVIOUS, "hex") + : null; export const blindIndexKey = Buffer.from(config.APP_BLIND_INDEX_KEY, "hex"); diff --git a/packages/api/src/cron/index.ts b/packages/api/src/cron/index.ts index 06ba751..8af2b13 100644 --- a/packages/api/src/cron/index.ts +++ b/packages/api/src/cron/index.ts @@ -1,9 +1,12 @@ import cron from "node-cron"; -import { PrismaClient } from "@prisma/client"; +import { unlink } from "node:fs/promises"; +import { resolve } from "node:path"; +import prisma from "../lib/prisma.js"; import { config } from "../config.js"; import { cleanExpiredChallenges } from "../routes/passkey.js"; - -const prisma = new PrismaClient(); +import { cleanupExpiredTokens } from "../lib/token-blocklist.js"; +import { getPluginCronJobs } from "../plugins/loader.js"; +import { cleanupViews } from "../lib/view-tracker.js"; export function startCronJobs() { // prune old activity events - daily at 3am @@ -38,23 +41,92 @@ export function startCronJobs() { } }); - // clean webauthn challenges - every 10 minutes - cron.schedule("*/10 * * * *", () => { + // clean webauthn challenges - every minute + cron.schedule("* * * * *", () => { cleanExpiredChallenges(); }); - // remove failed push subscriptions - daily at 5am - cron.schedule("0 5 * * *", async () => { - // subscriptions with no associated user get cleaned by cascade - // this handles any other stale ones - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - 30); - - const result = await prisma.pushSubscription.deleteMany({ - where: { createdAt: { lt: cutoff } }, + // clean expired recovery codes - daily at 3:30am + cron.schedule("30 3 * * *", async () => { + const result = await prisma.recoveryCode.deleteMany({ + where: { expiresAt: { lt: new Date() } }, }); if (result.count > 0) { - console.log(`Cleaned ${result.count} old push subscriptions`); + console.log(`Cleaned ${result.count} expired recovery codes`); } }); + + // clean expired blocked tokens - every 30 minutes + cron.schedule("*/30 * * * *", async () => { + const count = await cleanupExpiredTokens(); + if (count > 0) { + console.log(`Cleaned ${count} expired blocked tokens`); + } + }); + + // remove push subscriptions with too many failures - daily at 5am + cron.schedule("0 5 * * *", async () => { + const result = await prisma.pushSubscription.deleteMany({ + where: { failureCount: { gte: 3 } }, + }); + if (result.count > 0) { + console.log(`Cleaned ${result.count} failed push subscriptions`); + } + }); + + // clean orphaned attachments (uploaded but never linked) - hourly + cron.schedule("30 * * * *", async () => { + const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000); + + const orphans = await prisma.attachment.findMany({ + where: { + postId: null, + commentId: null, + createdAt: { lt: cutoff }, + }, + select: { id: true, path: true }, + }); + + if (orphans.length === 0) return; + + const ids = orphans.map((a) => a.id); + const result = await prisma.attachment.deleteMany({ + where: { id: { in: ids }, postId: null, commentId: null }, + }); + + if (result.count > 0) { + console.log(`Cleaned ${result.count} orphaned attachments`); + } + + for (const att of orphans) { + try { + await unlink(resolve(process.cwd(), "uploads", att.path)); + } catch {} + } + }); + + // clean expired view-tracker entries - every 5 minutes + cron.schedule("*/5 * * * *", () => { cleanupViews(); }); + + // register plugin-provided cron jobs (min interval: every minute, reject sub-minute) + for (const job of getPluginCronJobs()) { + if (!cron.validate(job.schedule)) { + console.error(`Plugin cron "${job.name}" has invalid schedule: ${job.schedule}, skipping`); + continue; + } + // reject schedules with 6 fields (seconds) to prevent sub-minute execution + const fields = job.schedule.trim().split(/\s+/); + if (fields.length > 5) { + console.error(`Plugin cron "${job.name}" uses sub-minute schedule, skipping`); + continue; + } + cron.schedule(job.schedule, async () => { + try { + await job.handler(); + } catch (err) { + console.error(`Plugin cron job "${job.name}" failed:`, err); + } + }); + console.log(`Registered plugin cron: ${job.name} (${job.schedule})`); + } } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 35c7ffc..58b4416 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,11 +1,27 @@ +import prisma from "./lib/prisma.js"; import { createServer } from "./server.js"; import { config } from "./config.js"; import { startCronJobs } from "./cron/index.js"; +import { reEncryptIfNeeded } from "./services/key-rotation.js"; +import { validateManifest } from "./services/manifest-validator.js"; async function main() { + validateManifest(); + + // ensure pg_trgm extension exists for similarity search + await prisma.$executeRaw`CREATE EXTENSION IF NOT EXISTS pg_trgm`; + + // search indexes for fuzzy + full-text search + await Promise.all([ + prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS idx_post_title_trgm ON "Post" USING gin (title gin_trgm_ops)`), + prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS idx_post_title_fts ON "Post" USING gin (to_tsvector('english', title))`), + prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS idx_board_name_trgm ON "Board" USING gin (name gin_trgm_ops)`), + ]); + const app = await createServer(); startCronJobs(); + reEncryptIfNeeded().catch((err) => console.error("Key rotation error:", err)); try { await app.listen({ port: config.PORT, host: "0.0.0.0" }); diff --git a/packages/api/src/lib/budget.ts b/packages/api/src/lib/budget.ts index 8c4b974..e2b2e15 100644 --- a/packages/api/src/lib/budget.ts +++ b/packages/api/src/lib/budget.ts @@ -1,6 +1,4 @@ -import { PrismaClient } from "@prisma/client"; - -const prisma = new PrismaClient(); +import prisma from "./prisma.js"; export function getCurrentPeriod(resetSchedule: string): string { const now = new Date(); @@ -9,17 +7,27 @@ export function getCurrentPeriod(resetSchedule: string): string { switch (resetSchedule) { case "weekly": { - const startOfYear = new Date(year, 0, 1); - const days = Math.floor((now.getTime() - startOfYear.getTime()) / 86400000); - const week = Math.ceil((days + startOfYear.getDay() + 1) / 7); - return `${year}-W${String(week).padStart(2, "0")}`; + // ISO 8601 week number + const d = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())); + d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; + } + case "biweekly": { + const d = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())); + d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + const biweek = Math.ceil(week / 2); + return `${d.getUTCFullYear()}-BW${String(biweek).padStart(2, "0")}`; } case "quarterly": { const q = Math.ceil((now.getMonth() + 1) / 3); return `${year}-Q${q}`; } - case "yearly": - return `${year}`; + case "per_release": + return "per_release"; case "never": return "lifetime"; case "monthly": @@ -28,17 +36,25 @@ export function getCurrentPeriod(resetSchedule: string): string { } } -export async function getRemainingBudget(userId: string, boardId: string): Promise { - const board = await prisma.board.findUnique({ where: { id: boardId } }); +export async function getRemainingBudget(userId: string, boardId: string, db: any = prisma): Promise { + const board = await db.board.findUnique({ where: { id: boardId } }); if (!board) return 0; if (board.voteBudgetReset === "never" && board.voteBudget === 0) { return Infinity; } - const period = getCurrentPeriod(board.voteBudgetReset); + // per_release uses the timestamp of the last manual reset as the period key + let period: string; + if (board.voteBudgetReset === "per_release") { + period = board.lastBudgetReset + ? `release-${board.lastBudgetReset.toISOString()}` + : "release-initial"; + } else { + period = getCurrentPeriod(board.voteBudgetReset); + } - const used = await prisma.vote.aggregate({ + const used = await db.vote.aggregate({ where: { voterId: userId, post: { boardId }, budgetPeriod: period }, _sum: { weight: true }, }); @@ -53,7 +69,16 @@ export function getNextResetDate(resetSchedule: string): Date { switch (resetSchedule) { case "weekly": { const d = new Date(now); - d.setDate(d.getDate() + (7 - d.getDay())); + const daysUntilMonday = ((8 - d.getDay()) % 7) || 7; + d.setDate(d.getDate() + daysUntilMonday); + d.setHours(0, 0, 0, 0); + return d; + } + case "biweekly": { + const d = new Date(now); + const dayOfWeek = d.getDay(); + const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek; + d.setDate(d.getDate() + daysUntilMonday + 7); d.setHours(0, 0, 0, 0); return d; } @@ -61,10 +86,11 @@ export function getNextResetDate(resetSchedule: string): Date { const q = Math.ceil((now.getMonth() + 1) / 3); return new Date(now.getFullYear(), q * 3, 1); } - case "yearly": - return new Date(now.getFullYear() + 1, 0, 1); + case "per_release": + // manual reset only - no automatic next date + return new Date(8640000000000000); case "never": - return new Date(8640000000000000); // max date + return new Date(8640000000000000); case "monthly": default: { const d = new Date(now.getFullYear(), now.getMonth() + 1, 1); diff --git a/packages/api/src/lib/default-templates.ts b/packages/api/src/lib/default-templates.ts new file mode 100644 index 0000000..0ded32c --- /dev/null +++ b/packages/api/src/lib/default-templates.ts @@ -0,0 +1,158 @@ +import { PrismaClient } from "@prisma/client"; + +interface TemplateField { + key: string; + label: string; + type: "text" | "textarea" | "select"; + required: boolean; + placeholder?: string; + options?: string[]; +} + +interface TemplateDef { + name: string; + fields: TemplateField[]; + isDefault: boolean; + position: number; +} + +const BUG_TEMPLATES: TemplateDef[] = [ + { + name: "Bug Report", + isDefault: true, + position: 0, + fields: [ + { key: "steps_to_reproduce", label: "Steps to reproduce", type: "textarea", required: true, placeholder: "1. Go to...\n2. Click on...\n3. See error" }, + { key: "expected_behavior", label: "Expected behavior", type: "textarea", required: true, placeholder: "What should have happened?" }, + { key: "actual_behavior", label: "Actual behavior", type: "textarea", required: true, placeholder: "What happened instead?" }, + { key: "environment", label: "Environment", type: "text", required: false, placeholder: "e.g. Chrome 120, Windows 11" }, + { key: "severity", label: "Severity", type: "select", required: true, options: ["Critical", "Major", "Minor", "Cosmetic"] }, + ], + }, + { + name: "UI/Visual Bug", + isDefault: false, + position: 1, + fields: [ + { key: "what_looks_wrong", label: "What looks wrong", type: "textarea", required: true, placeholder: "Describe the visual issue" }, + { key: "where_in_app", label: "Where in the app", type: "text", required: true, placeholder: "Page or screen name" }, + { key: "browser_device", label: "Browser and device", type: "text", required: true, placeholder: "e.g. Safari on iPhone 15" }, + { key: "screen_size", label: "Screen size", type: "text", required: false, placeholder: "e.g. 1920x1080 or mobile" }, + ], + }, + { + name: "Performance Issue", + isDefault: false, + position: 2, + fields: [ + { key: "whats_slow", label: "What's slow or laggy", type: "textarea", required: true }, + { key: "when_it_happens", label: "When does it happen", type: "textarea", required: true, placeholder: "Always, sometimes, under specific conditions..." }, + { key: "how_long", label: "How long does it take", type: "text", required: false, placeholder: "e.g. 10+ seconds to load" }, + { key: "device_network", label: "Device and network", type: "text", required: false, placeholder: "e.g. MacBook Pro, WiFi" }, + ], + }, + { + name: "Crash/Error Report", + isDefault: false, + position: 3, + fields: [ + { key: "what_happened", label: "What happened", type: "textarea", required: true }, + { key: "error_message", label: "Error message", type: "textarea", required: false, placeholder: "Copy the error text if visible" }, + { key: "steps_before_crash", label: "Steps before the crash", type: "textarea", required: true }, + { key: "frequency", label: "How often does it happen", type: "select", required: true, options: ["Every time", "Often", "Sometimes", "Once"] }, + ], + }, + { + name: "Data Issue", + isDefault: false, + position: 4, + fields: [ + { key: "what_data_is_wrong", label: "What data is wrong", type: "textarea", required: true }, + { key: "where_you_see_it", label: "Where do you see it", type: "text", required: true, placeholder: "Page, section, or API endpoint" }, + { key: "what_it_should_be", label: "What it should be", type: "textarea", required: true }, + { key: "impact", label: "Impact", type: "select", required: true, options: ["Blocking", "Major", "Minor"] }, + ], + }, +]; + +const FEATURE_TEMPLATES: TemplateDef[] = [ + { + name: "Feature Request", + isDefault: false, + position: 5, + fields: [ + { key: "use_case", label: "Use case", type: "textarea", required: true, placeholder: "What problem would this solve?" }, + { key: "proposed_solution", label: "Proposed solution", type: "textarea", required: true, placeholder: "How do you imagine this working?" }, + { key: "alternatives", label: "Alternatives considered", type: "textarea", required: false }, + { key: "who_benefits", label: "Who benefits", type: "text", required: false, placeholder: "e.g. All users, admins, new users" }, + ], + }, + { + name: "UX Improvement", + isDefault: false, + position: 6, + fields: [ + { key: "whats_confusing", label: "What's confusing or difficult", type: "textarea", required: true }, + { key: "how_should_it_work", label: "How should it work instead", type: "textarea", required: true }, + { key: "who_runs_into_this", label: "Who runs into this", type: "text", required: false, placeholder: "e.g. New users, power users" }, + { key: "frequency", label: "How often does this come up", type: "select", required: false, options: ["Daily", "Weekly", "Occasionally", "Rarely"] }, + ], + }, + { + name: "Integration Request", + isDefault: false, + position: 7, + fields: [ + { key: "tool_or_service", label: "Tool or service", type: "text", required: true, placeholder: "e.g. Slack, Jira, GitHub" }, + { key: "what_it_should_do", label: "What it should do", type: "textarea", required: true, placeholder: "Describe the integration behavior" }, + { key: "current_workaround", label: "Current workaround", type: "textarea", required: false }, + { key: "priority", label: "Priority", type: "select", required: true, options: ["Must have", "Should have", "Nice to have"] }, + ], + }, + { + name: "Content Change", + isDefault: false, + position: 8, + fields: [ + { key: "where", label: "Where is it", type: "text", required: true, placeholder: "Page, section, or URL" }, + { key: "current_text", label: "Current text", type: "textarea", required: true }, + { key: "suggested_text", label: "Suggested text", type: "textarea", required: true }, + { key: "why_change", label: "Why change it", type: "textarea", required: false }, + ], + }, + { + name: "Workflow Improvement", + isDefault: false, + position: 9, + fields: [ + { key: "current_workflow", label: "Current workflow", type: "textarea", required: true, placeholder: "Describe the steps you take today" }, + { key: "pain_point", label: "Pain point", type: "textarea", required: true, placeholder: "What's slow, repetitive, or error-prone?" }, + { key: "ideal_workflow", label: "Ideal workflow", type: "textarea", required: true, placeholder: "How should it work?" }, + { key: "impact", label: "Impact", type: "select", required: false, options: ["Saves significant time", "Reduces errors", "Improves quality", "Minor convenience"] }, + ], + }, +]; + +export const DEFAULT_TEMPLATES = [...BUG_TEMPLATES, ...FEATURE_TEMPLATES]; + +export async function seedTemplatesForBoard(prisma: PrismaClient, boardId: string) { + const existing = await prisma.boardTemplate.count({ where: { boardId } }); + if (existing > 0) return; + + await prisma.boardTemplate.createMany({ + data: DEFAULT_TEMPLATES.map((t) => ({ + boardId, + name: t.name, + fields: t.fields as any, + isDefault: t.isDefault, + position: t.position, + })), + }); +} + +export async function seedAllBoardTemplates(prisma: PrismaClient) { + const boards = await prisma.board.findMany({ select: { id: true } }); + for (const board of boards) { + await seedTemplatesForBoard(prisma, board.id); + } +} diff --git a/packages/api/src/lib/prisma.ts b/packages/api/src/lib/prisma.ts new file mode 100644 index 0000000..c24cdde --- /dev/null +++ b/packages/api/src/lib/prisma.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from "@prisma/client"; + +export const prisma = new PrismaClient(); + +export default prisma; diff --git a/packages/api/src/lib/token-blocklist.ts b/packages/api/src/lib/token-blocklist.ts new file mode 100644 index 0000000..91109ad --- /dev/null +++ b/packages/api/src/lib/token-blocklist.ts @@ -0,0 +1,30 @@ +import crypto from "crypto"; +import prisma from "./prisma.js"; + +function hashForBlock(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +export async function blockToken(token: string, ttlMs: number = 25 * 60 * 60 * 1000): Promise { + const tokenHash = hashForBlock(token); + const expiresAt = new Date(Date.now() + ttlMs); + await prisma.blockedToken.upsert({ + where: { tokenHash }, + create: { tokenHash, expiresAt }, + update: { expiresAt }, + }); +} + +export async function isTokenBlocked(token: string): Promise { + const tokenHash = hashForBlock(token); + const entry = await prisma.blockedToken.findUnique({ where: { tokenHash } }); + if (!entry) return false; + return entry.expiresAt > new Date(); +} + +export async function cleanupExpiredTokens(): Promise { + const result = await prisma.blockedToken.deleteMany({ + where: { expiresAt: { lt: new Date() } }, + }); + return result.count; +} diff --git a/packages/api/src/lib/view-tracker.ts b/packages/api/src/lib/view-tracker.ts new file mode 100644 index 0000000..40bbbfc --- /dev/null +++ b/packages/api/src/lib/view-tracker.ts @@ -0,0 +1,25 @@ +const MAX_ENTRIES = 50000; +const seen = new Map(); + +export function shouldCount(postId: string, identifier: string): boolean { + const key = `${postId}:${identifier}`; + const expiry = seen.get(key); + if (expiry && expiry > Date.now()) return false; + if (seen.size >= MAX_ENTRIES) { + const now = Date.now(); + for (const [k, v] of seen) { + if (v < now) seen.delete(k); + if (seen.size < MAX_ENTRIES * 0.8) break; + } + if (seen.size >= MAX_ENTRIES) return false; + } + seen.set(key, Date.now() + 15 * 60 * 1000); + return true; +} + +export function cleanupViews(): void { + const now = Date.now(); + for (const [k, v] of seen) { + if (v < now) seen.delete(k); + } +} diff --git a/packages/api/src/lib/wordlist.ts b/packages/api/src/lib/wordlist.ts new file mode 100644 index 0000000..ec28227 --- /dev/null +++ b/packages/api/src/lib/wordlist.ts @@ -0,0 +1,46 @@ +import { randomInt } from "node:crypto"; + +// 256 common, unambiguous English words (4-7 letters) +// 256^6 = ~2.8 * 10^14 combinations - strong enough with rate limiting + ALTCHA +const WORDS = [ + "acre", "alps", "arch", "army", "atom", "aura", "axis", "balm", + "band", "bark", "barn", "base", "bath", "beam", "bell", "belt", + "bend", "bird", "bite", "blow", "blur", "boat", "bold", "bolt", + "bond", "bone", "book", "bore", "boss", "bowl", "brim", "bulb", + "bulk", "burn", "bush", "buzz", "cafe", "cage", "calm", "camp", + "cape", "card", "cart", "case", "cast", "cave", "cell", "chat", + "chip", "city", "clam", "clan", "claw", "clay", "clip", "club", + "clue", "coal", "coat", "code", "coil", "coin", "cold", "cone", + "cook", "cool", "cope", "cord", "core", "cork", "corn", "cost", + "cove", "crew", "crop", "crow", "cube", "curb", "cure", "curl", + "dale", "dare", "dart", "dash", "dawn", "deck", "deer", "deli", + "demo", "dent", "desk", "dime", "disc", "dock", "dome", "door", + "dose", "dove", "draw", "drum", "dune", "dusk", "dust", "edge", + "emit", "epic", "exit", "face", "fact", "fame", "fawn", "felt", + "fern", "film", "find", "fire", "firm", "fish", "fist", "flag", + "flak", "flaw", "flex", "flip", "flow", "flux", "foam", "foil", + "fold", "folk", "font", "ford", "fork", "form", "fort", "frog", + "fuel", "fume", "fund", "fury", "fuse", "gale", "game", "gang", + "gate", "gaze", "gear", "germ", "gift", "gist", "glen", "glow", + "glue", "gold", "golf", "gong", "grab", "gram", "grid", "grin", + "grip", "grit", "gulf", "gust", "hail", "half", "hall", "halt", + "hare", "harp", "hawk", "haze", "heap", "helm", "herb", "herd", + "hero", "hike", "hill", "hint", "hive", "hold", "hole", "hood", + "hook", "hope", "horn", "host", "howl", "hull", "hunt", "husk", + "iris", "iron", "isle", "jade", "jazz", "jest", "jolt", "jump", + "jury", "keen", "kelp", "kite", "knob", "knot", "lace", "lake", + "lamb", "lamp", "lane", "lark", "lava", "lawn", "lead", "leaf", + "lens", "lien", "lime", "line", "link", "lion", "lock", "loft", + "loop", "loom", "lore", "luck", "lure", "lynx", "malt", "mane", + "maze", "mesa", "mild", "mill", "mine", "mint", "mist", "moat", + "mode", "mold", "monk", "moon", "moor", "moss", "myth", "navy", + "nest", "node", "norm", "note", "nova", "oaks", "opal", "orca", +]; + +export function generateRecoveryPhrase(): string { + const words: string[] = []; + for (let i = 0; i < 6; i++) { + words.push(WORDS[randomInt(WORDS.length)]); + } + return words.join("-"); +} diff --git a/packages/api/src/middleware/auth.ts b/packages/api/src/middleware/auth.ts index 90d0b50..68571fe 100644 --- a/packages/api/src/middleware/auth.ts +++ b/packages/api/src/middleware/auth.ts @@ -1,27 +1,33 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import fp from "fastify-plugin"; import jwt from "jsonwebtoken"; -import { PrismaClient, User } from "@prisma/client"; +import type { User } from "@prisma/client"; +import prisma from "../lib/prisma.js"; import { hashToken } from "../services/encryption.js"; import { config } from "../config.js"; +import { isTokenBlocked } from "../lib/token-blocklist.js"; declare module "fastify" { interface FastifyRequest { user?: User; adminId?: string; + adminRole?: string; } } -const prisma = new PrismaClient(); - async function authPlugin(app: FastifyInstance) { app.decorateRequest("user", undefined); app.decorateRequest("adminId", undefined); + app.decorateRequest("adminRole", undefined); app.decorate("requireUser", async (req: FastifyRequest, reply: FastifyReply) => { // try cookie auth first const token = req.cookies?.echoboard_token; if (token) { + if (await isTokenBlocked(token)) { + reply.status(401).send({ error: "Not authenticated" }); + return; + } const hash = hashToken(token); const user = await prisma.user.findUnique({ where: { tokenHash: hash } }); if (user) { @@ -30,11 +36,15 @@ async function authPlugin(app: FastifyInstance) { } } - // try bearer token (passkey sessions) - const authHeader = req.headers.authorization; - if (authHeader?.startsWith("Bearer ")) { + // try passkey session cookie, then fall back to Authorization header + const passkeyToken = req.cookies?.echoboard_passkey + || (req.headers.authorization?.startsWith("Bearer ") + ? req.headers.authorization.slice(7) + : null); + + if (passkeyToken && !(await isTokenBlocked(passkeyToken))) { try { - const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string }; + const decoded = jwt.verify(passkeyToken, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:user', issuer: 'echoboard' }) as { sub: string; type: string }; if (decoded.type === "passkey") { const user = await prisma.user.findUnique({ where: { id: decoded.sub } }); if (user) { @@ -53,15 +63,23 @@ async function authPlugin(app: FastifyInstance) { app.decorate("optionalUser", async (req: FastifyRequest) => { const token = req.cookies?.echoboard_token; if (token) { + if (await isTokenBlocked(token)) return; const hash = hashToken(token); const user = await prisma.user.findUnique({ where: { tokenHash: hash } }); - if (user) req.user = user; - return; + if (user) { + req.user = user; + return; + } } - const authHeader = req.headers.authorization; - if (authHeader?.startsWith("Bearer ")) { + + const passkeyToken = req.cookies?.echoboard_passkey + || (req.headers.authorization?.startsWith("Bearer ") + ? req.headers.authorization.slice(7) + : null); + + if (passkeyToken && !(await isTokenBlocked(passkeyToken))) { try { - const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string }; + const decoded = jwt.verify(passkeyToken, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:user', issuer: 'echoboard' }) as { sub: string; type: string }; if (decoded.type === "passkey") { const user = await prisma.user.findUnique({ where: { id: decoded.sub } }); if (user) req.user = user; @@ -72,22 +90,103 @@ async function authPlugin(app: FastifyInstance) { } }); - app.decorate("requireAdmin", async (req: FastifyRequest, reply: FastifyReply) => { - const authHeader = req.headers.authorization; - if (!authHeader?.startsWith("Bearer ")) { - reply.status(401).send({ error: "Admin token required" }); - return; + app.decorate("optionalAdmin", async (req: FastifyRequest) => { + const token = req.cookies?.echoboard_admin; + if (token && !(await isTokenBlocked(token))) { + try { + const decoded = jwt.verify(token, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:admin', issuer: 'echoboard' }) as { sub: string; type: string }; + if (decoded.type === "admin") { + const admin = await prisma.adminUser.findUnique({ where: { id: decoded.sub }, select: { id: true, role: true } }); + if (admin) { + req.adminId = decoded.sub; + req.adminRole = admin.role; + return; + } + } + } catch {} } - try { - const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string }; - if (decoded.type !== "admin") { - reply.status(403).send({ error: "Admin access required" }); + // fallback: check if authenticated user is a linked team member + if (req.user) { + const admin = await prisma.adminUser.findUnique({ + where: { linkedUserId: req.user.id }, + select: { id: true, role: true }, + }); + if (admin) { + req.adminId = admin.id; + req.adminRole = admin.role; + } + } + }); + + app.decorate("requireAdmin", async (req: FastifyRequest, reply: FastifyReply) => { + // try admin JWT cookie first (super admin login flow) + const token = req.cookies?.echoboard_admin ?? null; + if (token && !(await isTokenBlocked(token))) { + try { + const decoded = jwt.verify(token, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:admin', issuer: 'echoboard' }) as { sub: string; type: string }; + if (decoded.type === "admin") { + const admin = await prisma.adminUser.findUnique({ where: { id: decoded.sub }, select: { id: true, role: true } }); + if (admin) { + req.adminId = admin.id; + req.adminRole = admin.role; + return; + } + } + } catch {} + } + + // fallback: check if user is authenticated via cookie and linked to an admin + const cookieToken = req.cookies?.echoboard_token; + if (cookieToken && !(await isTokenBlocked(cookieToken))) { + const hash = hashToken(cookieToken); + const user = await prisma.user.findUnique({ where: { tokenHash: hash } }); + if (user) { + const admin = await prisma.adminUser.findUnique({ + where: { linkedUserId: user.id }, + select: { id: true, role: true }, + }); + if (admin) { + req.user = user; + req.adminId = admin.id; + req.adminRole = admin.role; + return; + } + } + } + + // try passkey token + const passkeyToken = req.cookies?.echoboard_passkey + || (req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : null); + if (passkeyToken && !(await isTokenBlocked(passkeyToken))) { + try { + const decoded = jwt.verify(passkeyToken, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:user', issuer: 'echoboard' }) as { sub: string; type: string }; + if (decoded.type === "passkey") { + const user = await prisma.user.findUnique({ where: { id: decoded.sub } }); + if (user) { + const admin = await prisma.adminUser.findUnique({ + where: { linkedUserId: user.id }, + select: { id: true, role: true }, + }); + if (admin) { + req.user = user; + req.adminId = admin.id; + req.adminRole = admin.role; + return; + } + } + } + } catch {} + } + + reply.status(401).send({ error: "Unauthorized" }); + }); + app.decorate("requireRole", (...roles: string[]) => { + return async (req: FastifyRequest, reply: FastifyReply) => { + if (!req.adminId || !req.adminRole || !roles.includes(req.adminRole)) { + reply.status(403).send({ error: "Insufficient permissions" }); return; } - req.adminId = decoded.sub; - } catch { - reply.status(401).send({ error: "Invalid admin token" }); - } + }; }); } @@ -95,7 +194,9 @@ declare module "fastify" { interface FastifyInstance { requireUser: (req: FastifyRequest, reply: FastifyReply) => Promise; optionalUser: (req: FastifyRequest) => Promise; + optionalAdmin: (req: FastifyRequest) => Promise; requireAdmin: (req: FastifyRequest, reply: FastifyReply) => Promise; + requireRole: (...roles: string[]) => (req: FastifyRequest, reply: FastifyReply) => Promise; } } diff --git a/packages/api/src/middleware/security.ts b/packages/api/src/middleware/security.ts index 1694e54..378ef9e 100644 --- a/packages/api/src/middleware/security.ts +++ b/packages/api/src/middleware/security.ts @@ -2,26 +2,51 @@ import { FastifyInstance } from "fastify"; import fp from "fastify-plugin"; async function securityPlugin(app: FastifyInstance) { - app.addHook("onSend", async (_req, reply) => { - reply.header("Content-Security-Policy", [ - "default-src 'self'", - "script-src 'self'", - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data:", - "font-src 'self'", - "connect-src 'self'", - "frame-ancestors 'none'", - "base-uri 'self'", - "form-action 'self'", - ].join("; ")); + app.addHook("onSend", async (req, reply) => { + const isEmbed = req.url.startsWith("/api/v1/embed/") || req.url.startsWith("/embed/"); + + if (isEmbed) { + // embed routes need to be frameable by third-party sites + reply.header("Content-Security-Policy", [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self'", + "connect-src 'self'", + "frame-src 'none'", + "object-src 'none'", + "frame-ancestors *", + "base-uri 'self'", + "form-action 'none'", + ].join("; ")); + reply.header("Cross-Origin-Resource-Policy", "cross-origin"); + reply.header("Access-Control-Allow-Origin", "*"); + reply.header("Access-Control-Allow-Credentials", "false"); + } else { + reply.header("Content-Security-Policy", [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self'", + "connect-src 'self'", + "frame-src 'none'", + "object-src 'none'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join("; ")); + reply.header("X-Frame-Options", "DENY"); + reply.header("Cross-Origin-Opener-Policy", "same-origin"); + reply.header("Cross-Origin-Resource-Policy", "same-origin"); + } + reply.header("Referrer-Policy", "no-referrer"); reply.header("X-Content-Type-Options", "nosniff"); - reply.header("X-Frame-Options", "DENY"); - reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); - reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + reply.header("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload"); + reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()"); reply.header("X-DNS-Prefetch-Control", "off"); - reply.header("Cross-Origin-Opener-Policy", "same-origin"); - reply.header("Cross-Origin-Resource-Policy", "same-origin"); }); } diff --git a/packages/api/src/plugins/loader.ts b/packages/api/src/plugins/loader.ts index ab4f8df..ba1af84 100644 --- a/packages/api/src/plugins/loader.ts +++ b/packages/api/src/plugins/loader.ts @@ -1,17 +1,46 @@ import { FastifyInstance } from "fastify"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { existsSync } from "node:fs"; import { PluginManifest, EchoboardPlugin } from "./types.js"; +const loadedPlugins: EchoboardPlugin[] = []; + export async function loadPlugins(app: FastifyInstance) { - const manifestPath = resolve(process.cwd(), "echoboard.plugins.json"); + // try dynamic import of echoboard.plugins.ts (compiled to .js in production) + const tsPath = resolve(process.cwd(), "echoboard.plugins.js"); + const tsSourcePath = resolve(process.cwd(), "echoboard.plugins.ts"); + + // The plugin directory must be write-protected in production to prevent + // unauthorized code from being loaded via this path. + if (existsSync(tsPath) || existsSync(tsSourcePath)) { + try { + const modPath = existsSync(tsPath) ? tsPath : tsSourcePath; + console.warn(`[plugins] loading plugin file: ${modPath}`); + const mod = await import(pathToFileURL(modPath).href); + const plugins: EchoboardPlugin[] = mod.plugins ?? mod.default?.plugins ?? []; + + for (const plugin of plugins) { + app.log.info(`Loading plugin: ${plugin.name} v${plugin.version}`); + await plugin.onRegister(app, {}); + loadedPlugins.push(plugin); + } + return; + } catch (err) { + app.log.error(`Failed to load plugins from echoboard.plugins: ${err}`); + } + } + + // fallback: JSON manifest + const jsonPath = resolve(process.cwd(), "echoboard.plugins.json"); let manifest: PluginManifest; try { - const raw = await readFile(manifestPath, "utf-8"); + const raw = await readFile(jsonPath, "utf-8"); manifest = JSON.parse(raw); } catch { - app.log.info("No plugin manifest found, skipping plugin loading"); + app.log.info("No plugin manifest found, running without plugins"); return; } @@ -20,13 +49,72 @@ export async function loadPlugins(app: FastifyInstance) { for (const entry of manifest.plugins) { if (!entry.enabled) continue; + // reject package names that look like paths or URLs to prevent arbitrary code loading + if (/[\/\\]|^\./.test(entry.name) || /^https?:/.test(entry.name)) { + app.log.error(`Skipping plugin "${entry.name}": paths and URLs not allowed in JSON manifest`); + continue; + } + // only allow scoped or plain npm package names + if (!/^(@[a-z0-9-]+\/)?[a-z0-9-]+$/.test(entry.name)) { + app.log.error(`Skipping plugin "${entry.name}": invalid package name`); + continue; + } + try { const mod = await import(entry.name) as { default: EchoboardPlugin }; const plugin = mod.default; app.log.info(`Loading plugin: ${plugin.name} v${plugin.version}`); - await plugin.register(app, entry.config ?? {}); + await plugin.onRegister(app, entry.config ?? {}); + loadedPlugins.push(plugin); } catch (err) { app.log.error(`Failed to load plugin ${entry.name}: ${err}`); } } } + +export async function startupPlugins() { + for (const plugin of loadedPlugins) { + if (plugin.onStartup) { + await plugin.onStartup(); + } + } +} + +export async function shutdownPlugins() { + for (const plugin of loadedPlugins) { + if (plugin.onShutdown) { + await plugin.onShutdown(); + } + } +} + +export function getPluginCronJobs() { + return loadedPlugins.flatMap((p) => p.getCronJobs?.() ?? []); +} + +export function getPluginAdminRoutes() { + return loadedPlugins.flatMap((p) => p.getAdminRoutes?.() ?? []); +} + +export function getPluginComponents() { + const merged: Record unknown)[]> = {}; + for (const plugin of loadedPlugins) { + const comps = plugin.getFrontendComponents?.(); + if (!comps) continue; + for (const [slot, comp] of Object.entries(comps)) { + if (!merged[slot]) merged[slot] = []; + merged[slot].push(comp); + } + } + return merged; +} + +export function getActivePluginInfo() { + return loadedPlugins.map((p) => ({ + name: p.name, + version: p.version, + adminRoutes: p.getAdminRoutes?.() ?? [], + slots: Object.keys(p.getFrontendComponents?.() ?? {}), + hasBoardSource: !!p.getBoardSource, + })); +} diff --git a/packages/api/src/plugins/types.ts b/packages/api/src/plugins/types.ts index 383f13d..792ae15 100644 --- a/packages/api/src/plugins/types.ts +++ b/packages/api/src/plugins/types.ts @@ -1,9 +1,58 @@ import { FastifyInstance } from "fastify"; +export interface AdminRoute { + path: string; + label: string; + component: string; +} + +export interface CronJobDefinition { + name: string; + schedule: string; + handler: () => Promise; +} + +export interface BoardSource { + name: string; + fetchBoards: () => Promise<{ slug: string; name: string; description?: string; externalUrl?: string }[]>; +} + +export interface ComponentMap { + [slotName: string]: () => unknown; +} + +export interface PrismaMigration { + name: string; + sql: string; +} + +export interface PrismaModelDefinition { + name: string; + schema: string; +} + export interface EchoboardPlugin { name: string; version: string; - register: (app: FastifyInstance, config: Record) => Promise; + + // Lifecycle + onRegister(app: FastifyInstance, config: Record): void | Promise; + onStartup?(): Promise; + onShutdown?(): Promise; + + // Database + getMigrations?(): PrismaMigration[]; + getModels?(): PrismaModelDefinition[]; + + // UI + getAdminRoutes?(): AdminRoute[]; + getFrontendComponents?(): ComponentMap; + + // Scheduled tasks + getCronJobs?(): CronJobDefinition[]; + + // Board creation + getBoardSource?(): BoardSource; } export interface PluginConfig { diff --git a/packages/api/src/routes/activity.ts b/packages/api/src/routes/activity.ts index 82d302c..dbb1796 100644 --- a/packages/api/src/routes/activity.ts +++ b/packages/api/src/routes/activity.ts @@ -1,19 +1,24 @@ import { FastifyInstance } from "fastify"; -import { PrismaClient, Prisma } from "@prisma/client"; +import { Prisma } from "@prisma/client"; import { z } from "zod"; +import prisma from "../lib/prisma.js"; -const prisma = new PrismaClient(); +const VALID_EVENT_TYPES = [ + "post_created", "admin_responded", "comment_created", "comment_edited", + "vote_cast", "vote_milestone", "status_changed", "post_deleted", +] as const; const querySchema = z.object({ board: z.string().optional(), - type: z.string().optional(), - page: z.coerce.number().min(1).default(1), - limit: z.coerce.number().min(1).max(100).default(30), + type: z.enum(VALID_EVENT_TYPES).optional(), + page: z.coerce.number().int().min(1).max(500).default(1), + limit: z.coerce.number().int().min(1).max(100).default(30), }); export default async function activityRoutes(app: FastifyInstance) { app.get<{ Querystring: Record }>( "/activity", + { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => { const q = querySchema.parse(req.query); diff --git a/packages/api/src/routes/admin/auth.ts b/packages/api/src/routes/admin/auth.ts index f9a2b2c..d83c76d 100644 --- a/packages/api/src/routes/admin/auth.ts +++ b/packages/api/src/routes/admin/auth.ts @@ -1,42 +1,156 @@ import { FastifyInstance } from "fastify"; -import { PrismaClient } from "@prisma/client"; import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import { z } from "zod"; import { config } from "../../config.js"; +import { encrypt, decrypt } from "../../services/encryption.js"; +import { masterKey } from "../../config.js"; +import { blockToken } from "../../lib/token-blocklist.js"; +import { prisma } from "../../lib/prisma.js"; -const prisma = new PrismaClient(); +const DUMMY_HASH = bcrypt.hashSync("__timing_safe__", 12); +const failedAttempts = new Map(); + +setInterval(() => { + const cutoff = Date.now() - 15 * 60 * 1000; + for (const [k, v] of failedAttempts) { + if (v.lastAttempt < cutoff) failedAttempts.delete(k); + } +}, 60 * 1000); const loginBody = z.object({ email: z.string().email(), password: z.string().min(1), }); +async function ensureLinkedUser(adminId: string): Promise { + return prisma.$transaction(async (tx) => { + const admin = await tx.adminUser.findUnique({ where: { id: adminId } }); + if (admin?.linkedUserId) return admin.linkedUserId; + + const displayName = encrypt("Admin", masterKey); + const user = await tx.user.create({ + data: { displayName, authMethod: "COOKIE" }, + }); + await tx.adminUser.update({ + where: { id: adminId }, + data: { linkedUserId: user.id }, + }); + return user.id; + }, { isolationLevel: "Serializable" }); +} + export default async function adminAuthRoutes(app: FastifyInstance) { app.post<{ Body: z.infer }>( "/admin/login", + { config: { rateLimit: { max: 5, timeWindow: "15 minutes" } } }, async (req, reply) => { const body = loginBody.parse(req.body); const admin = await prisma.adminUser.findUnique({ where: { email: body.email } }); - if (!admin) { + const valid = await bcrypt.compare(body.password, admin?.passwordHash ?? DUMMY_HASH); + + if (!admin || !valid) { + const bruteKey = `${req.ip}:${body.email.toLowerCase()}`; + if (!failedAttempts.has(bruteKey) && failedAttempts.size > 10000) { + const sorted = [...failedAttempts.entries()].sort((a, b) => a[1].lastAttempt - b[1].lastAttempt); + for (let i = 0; i < sorted.length / 2; i++) failedAttempts.delete(sorted[i][0]); + } + const entry = failedAttempts.get(bruteKey) || { count: 0, lastAttempt: 0 }; + entry.count++; + entry.lastAttempt = Date.now(); + failedAttempts.set(bruteKey, entry); + const delay = Math.min(100 * Math.pow(2, entry.count - 1), 10000); + await new Promise((r) => setTimeout(r, delay)); reply.status(401).send({ error: "Invalid credentials" }); return; } + failedAttempts.delete(`${req.ip}:${body.email.toLowerCase()}`); - const valid = await bcrypt.compare(body.password, admin.passwordHash); - if (!valid) { - reply.status(401).send({ error: "Invalid credentials" }); - return; + // only auto-upgrade CLI-created admins (have email, not invited) + if (admin.role !== "SUPER_ADMIN" && admin.email && !admin.invitedById) { + await prisma.adminUser.update({ where: { id: admin.id }, data: { role: "SUPER_ADMIN" } }); } - const token = jwt.sign( - { sub: admin.id, type: "admin" }, + const linkedUserId = await ensureLinkedUser(admin.id); + + const adminToken = jwt.sign( + { sub: admin.id, type: "admin", aud: "echoboard:admin", iss: "echoboard" }, config.JWT_SECRET, - { expiresIn: "24h" } + { expiresIn: "4h" } ); - reply.send({ token }); + const userToken = jwt.sign( + { sub: linkedUserId, type: "passkey", aud: "echoboard:user", iss: "echoboard" }, + config.JWT_SECRET, + { expiresIn: "4h" } + ); + + reply + .setCookie("echoboard_admin", adminToken, { + path: "/", + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 4, + }) + .setCookie("echoboard_passkey", userToken, { + path: "/", + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 4, + }) + .send({ ok: true }); } ); + + app.get( + "/admin/me", + { preHandler: [app.optionalUser, app.optionalAdmin], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, + async (req, reply) => { + if (!req.adminId) { + reply.send({ isAdmin: false }); + return; + } + const admin = await prisma.adminUser.findUnique({ + where: { id: req.adminId }, + select: { role: true, displayName: true, teamTitle: true }, + }); + if (!admin) { + reply.send({ isAdmin: false }); + return; + } + reply.send({ + isAdmin: true, + role: admin.role, + displayName: admin.displayName ? decrypt(admin.displayName, masterKey) : null, + teamTitle: admin.teamTitle ? decrypt(admin.teamTitle, masterKey) : null, + }); + } + ); + + app.post("/admin/exit", { preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => { + const adminToken = req.cookies?.echoboard_admin; + const passkeyToken = req.cookies?.echoboard_passkey; + if (adminToken) await blockToken(adminToken); + if (passkeyToken) await blockToken(passkeyToken); + reply + .clearCookie("echoboard_admin", { path: "/" }) + .clearCookie("echoboard_passkey", { path: "/" }) + .clearCookie("echoboard_token", { path: "/" }) + .send({ ok: true }); + }); + + app.post("/admin/logout", { preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => { + const adminToken = req.cookies?.echoboard_admin; + const passkeyToken = req.cookies?.echoboard_passkey; + if (adminToken) await blockToken(adminToken); + if (passkeyToken) await blockToken(passkeyToken); + reply + .clearCookie("echoboard_admin", { path: "/" }) + .clearCookie("echoboard_passkey", { path: "/" }) + .clearCookie("echoboard_token", { path: "/" }) + .send({ ok: true }); + }); } diff --git a/packages/api/src/routes/admin/boards.ts b/packages/api/src/routes/admin/boards.ts index 4020d5d..d628e89 100644 --- a/packages/api/src/routes/admin/boards.ts +++ b/packages/api/src/routes/admin/boards.ts @@ -1,33 +1,47 @@ import { FastifyInstance } from "fastify"; -import { PrismaClient } from "@prisma/client"; import { z } from "zod"; +import { seedTemplatesForBoard } from "../../lib/default-templates.js"; +import { prisma } from "../../lib/prisma.js"; -const prisma = new PrismaClient(); +const safeUrl = z.string().url().refine((u) => /^https?:\/\//i.test(u), { message: "URL must use http or https" }); + +const iconName = z.string().max(80).regex(/^Icon[A-Za-z0-9]+$/).optional().nullable(); +const iconColor = z.string().max(30).regex(/^#[0-9a-fA-F]{3,8}$/).optional().nullable(); const createBoardBody = z.object({ slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/), name: z.string().min(1).max(100), description: z.string().max(500).optional(), - externalUrl: z.string().url().optional(), + externalUrl: safeUrl.optional(), + iconName, + iconColor, voteBudget: z.number().int().min(0).default(10), - voteBudgetReset: z.enum(["weekly", "monthly", "quarterly", "yearly", "never"]).default("monthly"), + voteBudgetReset: z.enum(["weekly", "biweekly", "monthly", "quarterly", "per_release", "never"]).default("monthly"), allowMultiVote: z.boolean().default(false), + rssEnabled: z.boolean().default(true), + rssFeedCount: z.number().int().min(1).max(200).default(50), + staleDays: z.number().int().min(0).max(365).default(0), }); const updateBoardBody = z.object({ name: z.string().min(1).max(100).optional(), description: z.string().max(500).optional().nullable(), - externalUrl: z.string().url().optional().nullable(), + externalUrl: safeUrl.optional().nullable(), + iconName, + iconColor, isArchived: z.boolean().optional(), voteBudget: z.number().int().min(0).optional(), - voteBudgetReset: z.enum(["weekly", "monthly", "quarterly", "yearly", "never"]).optional(), + voteBudgetReset: z.enum(["weekly", "biweekly", "monthly", "quarterly", "per_release", "never"]).optional(), allowMultiVote: z.boolean().optional(), + rssEnabled: z.boolean().optional(), + rssFeedCount: z.number().int().min(1).max(200).optional(), + staleDays: z.number().int().min(0).max(365).optional(), }); export default async function adminBoardRoutes(app: FastifyInstance) { app.get( "/admin/boards", - { preHandler: [app.requireAdmin] }, + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (_req, reply) => { const boards = await prisma.board.findMany({ orderBy: { createdAt: "asc" }, @@ -41,7 +55,7 @@ export default async function adminBoardRoutes(app: FastifyInstance) { app.post<{ Body: z.infer }>( "/admin/boards", - { preHandler: [app.requireAdmin] }, + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, async (req, reply) => { const body = createBoardBody.parse(req.body); @@ -52,13 +66,15 @@ export default async function adminBoardRoutes(app: FastifyInstance) { } const board = await prisma.board.create({ data: body }); + await seedTemplatesForBoard(prisma, board.id); + req.log.info({ adminId: req.adminId, boardId: board.id, slug: board.slug }, "board created"); reply.status(201).send(board); } ); app.put<{ Params: { id: string }; Body: z.infer }>( "/admin/boards/:id", - { preHandler: [app.requireAdmin] }, + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, async (req, reply) => { const board = await prisma.board.findUnique({ where: { id: req.params.id } }); if (!board) { @@ -72,13 +88,14 @@ export default async function adminBoardRoutes(app: FastifyInstance) { data: body, }); + req.log.info({ adminId: req.adminId, boardId: board.id }, "board updated"); reply.send(updated); } ); app.post<{ Params: { id: string } }>( "/admin/boards/:id/reset-budget", - { preHandler: [app.requireAdmin] }, + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, async (req, reply) => { const board = await prisma.board.findUnique({ where: { id: req.params.id } }); if (!board) { @@ -91,21 +108,33 @@ export default async function adminBoardRoutes(app: FastifyInstance) { data: { lastBudgetReset: new Date() }, }); + req.log.info({ adminId: req.adminId, boardId: board.id }, "budget reset"); reply.send(updated); } ); app.delete<{ Params: { id: string } }>( "/admin/boards/:id", - { preHandler: [app.requireAdmin] }, + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, async (req, reply) => { - const board = await prisma.board.findUnique({ where: { id: req.params.id } }); + const board = await prisma.board.findUnique({ + where: { id: req.params.id }, + include: { _count: { select: { posts: true } } }, + }); if (!board) { reply.status(404).send({ error: "Board not found" }); return; } + if (board._count.posts > 0) { + reply.status(409).send({ + error: `Board has ${board._count.posts} posts. Archive it or delete posts first.`, + }); + return; + } + await prisma.board.delete({ where: { id: board.id } }); + req.log.info({ adminId: req.adminId, boardId: board.id, boardSlug: board.slug }, "board deleted"); reply.status(204).send(); } ); diff --git a/packages/api/src/routes/admin/categories.ts b/packages/api/src/routes/admin/categories.ts index e5d713a..b35dd18 100644 --- a/packages/api/src/routes/admin/categories.ts +++ b/packages/api/src/routes/admin/categories.ts @@ -1,8 +1,6 @@ import { FastifyInstance } from "fastify"; -import { PrismaClient } from "@prisma/client"; import { z } from "zod"; - -const prisma = new PrismaClient(); +import { prisma } from "../../lib/prisma.js"; const createCategoryBody = z.object({ name: z.string().min(1).max(50), @@ -12,26 +10,27 @@ const createCategoryBody = z.object({ export default async function adminCategoryRoutes(app: FastifyInstance) { app.post<{ Body: z.infer }>( "/admin/categories", - { preHandler: [app.requireAdmin] }, + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, async (req, reply) => { const body = createCategoryBody.parse(req.body); - const existing = await prisma.category.findFirst({ - where: { OR: [{ name: body.name }, { slug: body.slug }] }, - }); - if (existing) { - reply.status(409).send({ error: "Category already exists" }); - return; + try { + const cat = await prisma.category.create({ data: body }); + req.log.info({ adminId: req.adminId, categoryId: cat.id, name: cat.name }, "category created"); + reply.status(201).send(cat); + } catch (err: any) { + if (err.code === "P2002") { + reply.status(409).send({ error: "Category already exists" }); + return; + } + throw err; } - - const cat = await prisma.category.create({ data: body }); - reply.status(201).send(cat); } ); app.delete<{ Params: { id: string } }>( "/admin/categories/:id", - { preHandler: [app.requireAdmin] }, + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, async (req, reply) => { const cat = await prisma.category.findUnique({ where: { id: req.params.id } }); if (!cat) { @@ -40,6 +39,7 @@ export default async function adminCategoryRoutes(app: FastifyInstance) { } await prisma.category.delete({ where: { id: cat.id } }); + req.log.info({ adminId: req.adminId, categoryId: cat.id, name: cat.name }, "category deleted"); reply.status(204).send(); } ); diff --git a/packages/api/src/routes/admin/changelog.ts b/packages/api/src/routes/admin/changelog.ts new file mode 100644 index 0000000..0df9818 --- /dev/null +++ b/packages/api/src/routes/admin/changelog.ts @@ -0,0 +1,91 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { prisma } from "../../lib/prisma.js"; + +const createBody = z.object({ + title: z.string().min(1).max(200).trim(), + body: z.string().min(1).max(10000).trim(), + boardId: z.string().optional().nullable(), + publishedAt: z.coerce.date().optional(), +}); + +const updateBody = z.object({ + title: z.string().min(1).max(200).trim().optional(), + body: z.string().min(1).max(10000).trim().optional(), + boardId: z.string().optional().nullable(), + publishedAt: z.coerce.date().optional(), +}); + +export default async function adminChangelogRoutes(app: FastifyInstance) { + app.get( + "/admin/changelog", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, + async (_req, reply) => { + const entries = await prisma.changelogEntry.findMany({ + include: { board: { select: { id: true, slug: true, name: true } } }, + orderBy: { publishedAt: "desc" }, + take: 200, + }); + reply.send({ entries }); + } + ); + + app.post<{ Body: z.infer }>( + "/admin/changelog", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const body = createBody.parse(req.body); + const entry = await prisma.changelogEntry.create({ + data: { + title: body.title, + body: body.body, + boardId: body.boardId || null, + publishedAt: body.publishedAt ?? new Date(), + }, + }); + req.log.info({ adminId: req.adminId, entryId: entry.id }, "changelog entry created"); + reply.status(201).send(entry); + } + ); + + app.put<{ Params: { id: string }; Body: z.infer }>( + "/admin/changelog/:id", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const entry = await prisma.changelogEntry.findUnique({ where: { id: req.params.id } }); + if (!entry) { + reply.status(404).send({ error: "Entry not found" }); + return; + } + + const body = updateBody.parse(req.body); + const updated = await prisma.changelogEntry.update({ + where: { id: entry.id }, + data: { + ...(body.title !== undefined && { title: body.title }), + ...(body.body !== undefined && { body: body.body }), + ...(body.boardId !== undefined && { boardId: body.boardId || null }), + ...(body.publishedAt !== undefined && { publishedAt: body.publishedAt }), + }, + }); + req.log.info({ adminId: req.adminId, entryId: entry.id }, "changelog entry updated"); + reply.send(updated); + } + ); + + app.delete<{ Params: { id: string } }>( + "/admin/changelog/:id", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const entry = await prisma.changelogEntry.findUnique({ where: { id: req.params.id } }); + if (!entry) { + reply.status(404).send({ error: "Entry not found" }); + return; + } + + await prisma.changelogEntry.delete({ where: { id: entry.id } }); + req.log.info({ adminId: req.adminId, entryId: entry.id }, "changelog entry deleted"); + reply.status(204).send(); + } + ); +} diff --git a/packages/api/src/routes/admin/export.ts b/packages/api/src/routes/admin/export.ts new file mode 100644 index 0000000..0bc08b8 --- /dev/null +++ b/packages/api/src/routes/admin/export.ts @@ -0,0 +1,164 @@ +import { FastifyInstance } from "fastify"; +import { decryptWithFallback } from "../../services/encryption.js"; +import { masterKey, previousMasterKey } from "../../config.js"; +import { prisma } from "../../lib/prisma.js"; + +function decryptName(encrypted: string | null): string { + if (!encrypted) return ""; + try { + return decryptWithFallback(encrypted, masterKey, previousMasterKey); + } catch { + return "[encrypted]"; + } +} + +function toCsv(headers: string[], rows: string[][]): string { + const escape = (v: string) => { + // prevent formula injection in spreadsheets + let safe = v; + if (/^[=+\-@\t\r|]/.test(safe)) { + safe = "'" + safe; + } + if (safe.includes(",") || safe.includes('"') || safe.includes("\n")) { + return '"' + safe.replace(/"/g, '""') + '"'; + } + return safe; + }; + const lines = [headers.map(escape).join(",")]; + for (const row of rows) { + lines.push(row.map((c) => escape(String(c ?? ""))).join(",")); + } + return lines.join("\n"); +} + +async function fetchPosts() { + const posts = await prisma.post.findMany({ + include: { board: { select: { name: true } }, author: { select: { displayName: true } } }, + orderBy: { createdAt: "desc" }, + take: 5000, + }); + return posts.map((p) => ({ + id: p.id, + title: p.title, + type: p.type, + status: p.status, + voteCount: p.voteCount, + board: p.board.name, + author: decryptName(p.author.displayName), + createdAt: p.createdAt.toISOString(), + })); +} + +async function fetchVotes() { + const votes = await prisma.vote.findMany({ + include: { + post: { select: { title: true } }, + voter: { select: { displayName: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 10000, + }); + return votes.map((v) => ({ + postId: v.postId, + postTitle: v.post.title, + voter: decryptName(v.voter.displayName), + weight: v.weight, + importance: v.importance ?? "", + createdAt: v.createdAt.toISOString(), + })); +} + +async function fetchComments() { + const comments = await prisma.comment.findMany({ + include: { + post: { select: { title: true } }, + author: { select: { displayName: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 10000, + }); + return comments.map((c) => ({ + postId: c.postId, + postTitle: c.post.title, + body: c.body, + author: decryptName(c.author.displayName), + isAdmin: c.isAdmin, + createdAt: c.createdAt.toISOString(), + })); +} + +async function fetchUsers() { + const users = await prisma.user.findMany({ orderBy: { createdAt: "desc" }, take: 5000 }); + return users.map((u) => ({ + id: u.id, + authMethod: u.authMethod, + displayName: decryptName(u.displayName), + createdAt: u.createdAt.toISOString(), + })); +} + +const postHeaders = ["id", "title", "type", "status", "voteCount", "board", "author", "createdAt"]; +const voteHeaders = ["postId", "postTitle", "voter", "weight", "importance", "createdAt"]; +const commentHeaders = ["postId", "postTitle", "body", "author", "isAdmin", "createdAt"]; +const userHeaders = ["id", "authMethod", "displayName", "createdAt"]; + +function toRows(items: Record[], headers: string[]): string[][] { + return items.map((item) => headers.map((h) => String(item[h] ?? ""))); +} + +export default async function adminExportRoutes(app: FastifyInstance) { + app.get( + "/admin/export", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 2, timeWindow: "1 minute" } } }, + async (req, reply) => { + const { format = "json", type = "all" } = req.query as { format?: string; type?: string }; + + const validTypes = new Set(["all", "posts", "votes", "comments", "users"]); + const validFormats = new Set(["json", "csv"]); + if (!validTypes.has(type) || !validFormats.has(format)) { + reply.status(400).send({ error: "Invalid export type or format" }); + return; + } + + req.log.info({ adminId: req.adminId, exportType: type, format }, "admin data export"); + + + const data: Record = {}; + + if (type === "posts" || type === "all") data.posts = await fetchPosts(); + if (type === "votes" || type === "all") data.votes = await fetchVotes(); + if (type === "comments" || type === "all") data.comments = await fetchComments(); + if (type === "users" || type === "all") data.users = await fetchUsers(); + + if (format === "csv") { + const parts: string[] = []; + + if (data.posts) { + parts.push("# Posts"); + parts.push(toCsv(postHeaders, toRows(data.posts as Record[], postHeaders))); + } + if (data.votes) { + parts.push("# Votes"); + parts.push(toCsv(voteHeaders, toRows(data.votes as Record[], voteHeaders))); + } + if (data.comments) { + parts.push("# Comments"); + parts.push(toCsv(commentHeaders, toRows(data.comments as Record[], commentHeaders))); + } + if (data.users) { + parts.push("# Users"); + parts.push(toCsv(userHeaders, toRows(data.users as Record[], userHeaders))); + } + + const csv = parts.join("\n\n"); + reply + .header("Content-Type", "text/csv; charset=utf-8") + .header("Content-Disposition", `attachment; filename="echoboard-export-${type}.csv"`) + .send(csv); + return; + } + + reply.send(data); + } + ); +} diff --git a/packages/api/src/routes/admin/notes.ts b/packages/api/src/routes/admin/notes.ts new file mode 100644 index 0000000..fd21b08 --- /dev/null +++ b/packages/api/src/routes/admin/notes.ts @@ -0,0 +1,100 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { prisma } from "../../lib/prisma.js"; + +function redactEmail(email: string): string { + const [local, domain] = email.split("@"); + if (!domain) return "***"; + return local[0] + "***@" + domain; +} + +const createNoteBody = z.object({ + body: z.string().min(1).max(2000).trim(), +}); + +export default async function adminNoteRoutes(app: FastifyInstance) { + // Get notes for a post + app.get<{ Params: { id: string } }>( + "/admin/posts/:id/notes", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, + async (req, reply) => { + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post) { + reply.status(404).send({ error: "Post not found" }); + return; + } + + const notes = await prisma.adminNote.findMany({ + where: { postId: post.id }, + orderBy: { createdAt: "desc" }, + take: 100, + include: { + admin: { select: { email: true } }, + }, + }); + + reply.send({ + notes: notes.map((n) => ({ + id: n.id, + body: n.body, + adminEmail: n.admin.email ? redactEmail(n.admin.email) : "Team member", + createdAt: n.createdAt, + })), + }); + } + ); + + // Add a note to a post + app.post<{ Params: { id: string }; Body: z.infer }>( + "/admin/posts/:id/notes", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post) { + reply.status(404).send({ error: "Post not found" }); + return; + } + + const { body } = createNoteBody.parse(req.body); + + const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! }, select: { email: true } }); + + const note = await prisma.adminNote.create({ + data: { + body, + postId: post.id, + adminId: req.adminId!, + }, + }); + + reply.status(201).send({ + id: note.id, + body: note.body, + postId: note.postId, + adminEmail: admin?.email ? redactEmail(admin.email) : "Team member", + createdAt: note.createdAt, + }); + } + ); + + // Delete a note + app.delete<{ Params: { noteId: string } }>( + "/admin/notes/:noteId", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const note = await prisma.adminNote.findUnique({ where: { id: req.params.noteId } }); + if (!note) { + reply.status(404).send({ error: "Note not found" }); + return; + } + + if (note.adminId !== req.adminId) { + reply.status(403).send({ error: "You can only delete your own notes" }); + return; + } + + await prisma.adminNote.delete({ where: { id: note.id } }); + reply.status(204).send(); + } + ); +} diff --git a/packages/api/src/routes/admin/posts.ts b/packages/api/src/routes/admin/posts.ts index 2c0b813..4bbf9bd 100644 --- a/packages/api/src/routes/admin/posts.ts +++ b/packages/api/src/routes/admin/posts.ts @@ -1,27 +1,87 @@ import { FastifyInstance } from "fastify"; -import { PrismaClient, PostStatus, Prisma } from "@prisma/client"; +import { Prisma, PostType } from "@prisma/client"; import { z } from "zod"; +import { unlink } from "node:fs/promises"; +import { resolve } from "node:path"; import { notifyPostSubscribers } from "../../services/push.js"; +import { fireWebhook } from "../../services/webhooks.js"; +import { decrypt } from "../../services/encryption.js"; +import { masterKey } from "../../config.js"; +import { prisma } from "../../lib/prisma.js"; -const prisma = new PrismaClient(); +const INVISIBLE_RE = /[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g; + +function decryptName(v: string | null): string | null { + if (!v) return null; + try { return decrypt(v, masterKey); } catch { return null; } +} const statusBody = z.object({ - status: z.nativeEnum(PostStatus), + status: z.string().min(1).max(50), + reason: z.string().max(2000).optional(), }); const respondBody = z.object({ body: z.string().min(1).max(5000), }); +const mergeBody = z.object({ + targetPostId: z.string().min(1), +}); + +const rollbackBody = z.object({ + editHistoryId: z.string().min(1), +}); + +const bulkIds = z.array(z.string().min(1)).min(1).max(100); + +const bulkStatusBody = z.object({ + postIds: bulkIds, + status: z.string().min(1).max(50), +}); + +const bulkDeleteBody = z.object({ + postIds: bulkIds, +}); + +const bulkTagBody = z.object({ + postIds: bulkIds, + tagId: z.string().min(1), + action: z.enum(['add', 'remove']), +}); + +const allowedDescKeys = new Set(["stepsToReproduce", "expectedBehavior", "actualBehavior", "environment", "useCase", "proposedSolution", "alternativesConsidered", "additionalContext"]); + +const descriptionRecord = z.record(z.string()).refine( + (obj) => Object.keys(obj).length <= 10 && Object.keys(obj).every((k) => allowedDescKeys.has(k)), + { message: "Unknown description fields" } +); + +const proxyPostBody = z.object({ + type: z.nativeEnum(PostType), + title: z.string().min(5).max(200), + description: descriptionRecord, + onBehalfOf: z.string().min(1).max(200), +}); + +const adminPostsQuery = z.object({ + page: z.coerce.number().int().min(1).max(500).default(1), + limit: z.coerce.number().int().min(1).max(100).default(50), + status: z.string().max(50).optional(), + boardId: z.string().min(1).optional(), +}); + export default async function adminPostRoutes(app: FastifyInstance) { app.get<{ Querystring: Record }>( "/admin/posts", - { preHandler: [app.requireAdmin] }, + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (req, reply) => { - const page = Math.max(1, parseInt(req.query.page ?? "1", 10)); - const limit = Math.min(100, Math.max(1, parseInt(req.query.limit ?? "50", 10))); - const status = req.query.status as PostStatus | undefined; - const boardId = req.query.boardId; + const q = adminPostsQuery.safeParse(req.query); + if (!q.success) { + reply.status(400).send({ error: "Invalid query parameters" }); + return; + } + const { page, limit, status, boardId } = q.data; const where: Prisma.PostWhereInput = {}; if (status) where.status = status; @@ -31,24 +91,46 @@ export default async function adminPostRoutes(app: FastifyInstance) { prisma.post.findMany({ where, orderBy: { createdAt: "desc" }, - skip: (page - 1) * limit, + skip: Math.min((page - 1) * limit, 50000), take: limit, include: { board: { select: { slug: true, name: true } }, - author: { select: { id: true, displayName: true } }, - _count: { select: { comments: true, votes: true } }, + author: { select: { id: true, displayName: true, avatarPath: true } }, + _count: { select: { comments: true, votes: true, adminNotes: true } }, + tags: { include: { tag: true } }, }, }), prisma.post.count({ where }), ]); - reply.send({ posts, total, page, pages: Math.ceil(total / limit) }); + reply.send({ + posts: posts.map((p) => ({ + id: p.id, + type: p.type, + title: p.title, + description: p.description, + status: p.status, + statusReason: p.statusReason, + category: p.category, + voteCount: p.voteCount, + viewCount: p.viewCount, + isPinned: p.isPinned, + onBehalfOf: p.onBehalfOf, + board: p.board, + author: p.author ? { id: p.author.id, displayName: decryptName(p.author.displayName), avatarUrl: p.author.avatarPath ? `/api/v1/avatars/${p.author.id}` : null } : null, + tags: p.tags.map((pt) => pt.tag), + _count: p._count, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + })), + total, page, pages: Math.ceil(total / limit), + }); } ); app.put<{ Params: { id: string }; Body: z.infer }>( "/admin/posts/:id/status", - { preHandler: [app.requireAdmin] }, + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, async (req, reply) => { const post = await prisma.post.findUnique({ where: { id: req.params.id } }); if (!post) { @@ -56,17 +138,37 @@ export default async function adminPostRoutes(app: FastifyInstance) { return; } - const { status } = statusBody.parse(req.body); + const { status, reason } = statusBody.parse(req.body); + const reasonText = reason?.trim() || null; + + // check if the target status exists and is enabled for this board + const boardConfig = await prisma.boardStatus.findMany({ + where: { boardId: post.boardId, enabled: true }, + }); + if (boardConfig.length === 0) { + reply.status(400).send({ error: "No statuses configured for this board" }); + return; + } + const statusEntry = boardConfig.find((c) => c.status === status); + if (!statusEntry) { + reply.status(400).send({ error: `Status "${status}" is not available for this board` }); + return; + } + const oldStatus = post.status; const [updated] = await Promise.all([ - prisma.post.update({ where: { id: post.id }, data: { status } }), + prisma.post.update({ + where: { id: post.id }, + data: { status, statusReason: reasonText, lastActivityAt: new Date() }, + }), prisma.statusChange.create({ data: { postId: post.id, fromStatus: oldStatus, toStatus: status, changedBy: req.adminId!, + reason: reasonText, }, }), prisma.activityEvent.create({ @@ -79,20 +181,55 @@ export default async function adminPostRoutes(app: FastifyInstance) { }), ]); + // notify post author and voters + const voters = await prisma.vote.findMany({ + where: { postId: post.id }, + select: { voterId: true }, + }); + const statusLabel = statusEntry.label || status.replace(/_/g, " "); + const notifBody = reasonText + ? `"${post.title}" moved to ${statusLabel} - Reason: ${reasonText}` + : `"${post.title}" moved to ${statusLabel}`; + const sentinelId = "deleted-user-sentinel"; + const voterIds = voters.map((v) => v.voterId).filter((id) => id !== sentinelId); + if (voterIds.length > 1000) { + req.log.warn({ postId: post.id, totalVoters: voterIds.length }, "notification capped at 1000 voters"); + } + const notifyIds = voterIds.slice(0, 1000); + const userIds = new Set([post.authorId, ...notifyIds]); + userIds.delete(sentinelId); + await prisma.notification.createMany({ + data: [...userIds].map((userId) => ({ + type: "status_changed", + title: "Status updated", + body: notifBody, + postId: post.id, + userId, + })), + }); + await notifyPostSubscribers(post.id, { title: "Status updated", - body: `"${post.title}" moved to ${status}`, + body: notifBody, url: `/post/${post.id}`, tag: `status-${post.id}`, }); + fireWebhook("status_changed", { + postId: post.id, + title: post.title, + boardId: post.boardId, + from: oldStatus, + to: status, + }); + reply.send(updated); } ); app.put<{ Params: { id: string } }>( "/admin/posts/:id/pin", - { preHandler: [app.requireAdmin] }, + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, async (req, reply) => { const post = await prisma.post.findUnique({ where: { id: req.params.id } }); if (!post) { @@ -100,65 +237,323 @@ export default async function adminPostRoutes(app: FastifyInstance) { return; } + if (!post.isPinned) { + try { + const updated = await prisma.$transaction(async (tx) => { + const pinnedCount = await tx.post.count({ + where: { boardId: post.boardId, isPinned: true }, + }); + if (pinnedCount >= 3) throw new Error("PIN_LIMIT"); + return tx.post.update({ + where: { id: post.id }, + data: { isPinned: true }, + }); + }, { isolationLevel: "Serializable" }); + reply.send(updated); + } catch (err: any) { + if (err.message === "PIN_LIMIT") { + reply.status(409).send({ error: "Max 3 pinned posts per board" }); + return; + } + throw err; + } + } else { + const updated = await prisma.post.update({ + where: { id: post.id }, + data: { isPinned: false }, + }); + reply.send(updated); + } + } + ); + + app.post<{ Params: { id: string }; Body: z.infer }>( + "/admin/posts/:id/respond", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post) { + reply.status(404).send({ error: "Post not found" }); + return; + } + const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } }); + if (!admin || !admin.linkedUserId) { + reply.status(500).send({ error: "Admin account not linked" }); + return; + } + + const { body } = respondBody.parse(req.body); + const cleanBody = body.replace(INVISIBLE_RE, ''); + + const comment = await prisma.comment.create({ + data: { + body: cleanBody, + postId: post.id, + authorId: admin.linkedUserId, + isAdmin: true, + adminUserId: req.adminId!, + }, + }); + + await prisma.activityEvent.create({ + data: { + type: "admin_responded", + boardId: post.boardId, + postId: post.id, + metadata: {}, + }, + }); + + await notifyPostSubscribers(post.id, { + title: "Official response", + body: cleanBody.slice(0, 100), + url: `/post/${post.id}`, + tag: `response-${post.id}`, + }); + + reply.status(201).send(comment); + } + ); + + // Admin creates a post on behalf of a user + app.post<{ Params: { boardId: string }; Body: z.infer }>( + "/admin/boards/:boardId/posts", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { id: req.params.boardId } }); + if (!board || board.isArchived) { + reply.status(404).send({ error: "Board not found or archived" }); + return; + } + + const body = proxyPostBody.parse(req.body); + + const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } }); + if (!admin || !admin.linkedUserId) { + reply.status(400).send({ error: "Admin account must be linked to a user to submit posts" }); + return; + } + + const cleanTitle = body.title.replace(INVISIBLE_RE, ''); + const cleanDesc: Record = {}; + for (const [k, v] of Object.entries(body.description)) { + cleanDesc[k] = v.replace(INVISIBLE_RE, ''); + } + + const post = await prisma.post.create({ + data: { + type: body.type, + title: cleanTitle, + description: cleanDesc, + boardId: board.id, + authorId: admin.linkedUserId, + onBehalfOf: body.onBehalfOf, + }, + }); + + await prisma.activityEvent.create({ + data: { + type: "post_created", + boardId: board.id, + postId: post.id, + metadata: { title: post.title, type: post.type, onBehalfOf: body.onBehalfOf }, + }, + }); + + fireWebhook("post_created", { + postId: post.id, + title: post.title, + type: post.type, + boardId: board.id, + boardSlug: board.slug, + onBehalfOf: body.onBehalfOf, + }); + + reply.status(201).send(post); + } + ); + + // Merge source post into target post + app.post<{ Params: { id: string }; Body: z.infer }>( + "/admin/posts/:id/merge", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 5, timeWindow: "1 minute" } } }, + async (req, reply) => { + const { targetPostId } = mergeBody.parse(req.body); + + const source = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!source) { + reply.status(404).send({ error: "Source post not found" }); + return; + } + + const target = await prisma.post.findUnique({ where: { id: targetPostId } }); + if (!target) { + reply.status(404).send({ error: "Target post not found" }); + return; + } + + if (source.id === target.id) { + reply.status(400).send({ error: "Cannot merge a post into itself" }); + return; + } + + if (source.boardId !== target.boardId) { + reply.status(400).send({ error: "Cannot merge posts across different boards" }); + return; + } + + // only load IDs and weights to minimize memory + const sourceVotes = await prisma.vote.findMany({ + where: { postId: source.id }, + select: { id: true, voterId: true, weight: true }, + }); + const targetVoterIds = new Set( + (await prisma.vote.findMany({ where: { postId: target.id }, select: { voterId: true } })) + .map((v) => v.voterId) + ); + + const votesToMove = sourceVotes.filter((v) => !targetVoterIds.has(v.voterId)); + + const targetTagIds = (await prisma.postTag.findMany({ + where: { postId: target.id }, + select: { tagId: true }, + })).map((t) => t.tagId); + + await prisma.$transaction(async (tx) => { + for (const v of votesToMove) { + await tx.vote.update({ where: { id: v.id }, data: { postId: target.id } }); + } + await tx.vote.deleteMany({ where: { postId: source.id } }); + await tx.comment.updateMany({ where: { postId: source.id }, data: { postId: target.id } }); + await tx.attachment.updateMany({ where: { postId: source.id }, data: { postId: target.id } }); + await tx.postTag.deleteMany({ + where: { postId: source.id, tagId: { in: targetTagIds } }, + }); + await tx.postTag.updateMany({ where: { postId: source.id }, data: { postId: target.id } }); + await tx.post.update({ + where: { id: target.id }, + data: { voteCount: { increment: votesToMove.reduce((sum, v) => sum + v.weight, 0) } }, + }); + await tx.postMerge.create({ + data: { sourcePostId: source.id, targetPostId: target.id, mergedBy: req.adminId! }, + }); + await tx.post.delete({ where: { id: source.id } }); + }, { isolationLevel: "Serializable" }); + + const actualCount = await prisma.vote.aggregate({ where: { postId: target.id }, _sum: { weight: true } }); + await prisma.post.update({ where: { id: target.id }, data: { voteCount: actualCount._sum.weight ?? 0 } }); + + req.log.info({ adminId: req.adminId, sourcePostId: source.id, targetPostId: target.id }, "posts merged"); + reply.send({ merged: true, targetPostId: target.id }); + } + ); + + app.put<{ Params: { id: string } }>( + "/admin/posts/:id/lock-edits", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post) { + reply.status(404).send({ error: "Post not found" }); + return; + } const updated = await prisma.post.update({ where: { id: post.id }, - data: { isPinned: !post.isPinned }, + data: { isEditLocked: !post.isEditLocked }, + }); + reply.send({ isEditLocked: updated.isEditLocked }); + } + ); + + app.put<{ Params: { id: string } }>( + "/admin/posts/:id/lock-thread", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const body = z.object({ lockVoting: z.boolean().optional() }).parse(req.body); + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post) { + reply.status(404).send({ error: "Post not found" }); + return; + } + const newThreadLocked = !post.isThreadLocked; + const data: Record = { isThreadLocked: newThreadLocked }; + if (newThreadLocked && body.lockVoting) { + data.isVotingLocked = true; + } + if (!newThreadLocked) { + data.isVotingLocked = false; + } + const updated = await prisma.post.update({ where: { id: post.id }, data }); + reply.send({ isThreadLocked: updated.isThreadLocked, isVotingLocked: updated.isVotingLocked }); + } + ); + + app.put<{ Params: { id: string } }>( + "/admin/comments/:id/lock-edits", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const comment = await prisma.comment.findUnique({ where: { id: req.params.id } }); + if (!comment) { + reply.status(404).send({ error: "Comment not found" }); + return; + } + const updated = await prisma.comment.update({ + where: { id: comment.id }, + data: { isEditLocked: !comment.isEditLocked }, + }); + reply.send({ isEditLocked: updated.isEditLocked }); + } + ); + + app.post<{ Params: { id: string }; Body: z.infer }>( + "/admin/posts/:id/rollback", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (req, reply) => { + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post) { + reply.status(404).send({ error: "Post not found" }); + return; + } + + const { editHistoryId } = rollbackBody.parse(req.body); + const edit = await prisma.editHistory.findUnique({ where: { id: editHistoryId } }); + if (!edit || edit.postId !== post.id) { + reply.status(404).send({ error: "Edit history entry not found" }); + return; + } + + const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } }); + if (!admin?.linkedUserId) { + reply.status(400).send({ error: "Admin account must be linked to a user" }); + return; + } + + // save current state before rollback + await prisma.editHistory.create({ + data: { + postId: post.id, + editedBy: admin.linkedUserId, + previousTitle: post.title, + previousDescription: post.description as any, + }, + }); + + const data: Record = {}; + if (edit.previousTitle !== null) data.title = edit.previousTitle; + if (edit.previousDescription !== null) data.description = edit.previousDescription; + + const updated = await prisma.post.update({ + where: { id: post.id }, + data, }); reply.send(updated); } ); - app.post<{ Params: { id: string }; Body: z.infer }>( - "/admin/posts/:id/respond", - { preHandler: [app.requireAdmin] }, - async (req, reply) => { - const post = await prisma.post.findUnique({ where: { id: req.params.id } }); - if (!post) { - reply.status(404).send({ error: "Post not found" }); - return; - } - - const { body } = respondBody.parse(req.body); - - const response = await prisma.adminResponse.create({ - data: { - body, - postId: post.id, - adminId: req.adminId!, - }, - include: { admin: { select: { id: true, email: true } } }, - }); - - await notifyPostSubscribers(post.id, { - title: "Official response", - body: body.slice(0, 100), - url: `/post/${post.id}`, - tag: `response-${post.id}`, - }); - - reply.status(201).send(response); - } - ); - - app.delete<{ Params: { id: string } }>( - "/admin/posts/:id", - { preHandler: [app.requireAdmin] }, - async (req, reply) => { - const post = await prisma.post.findUnique({ where: { id: req.params.id } }); - if (!post) { - reply.status(404).send({ error: "Post not found" }); - return; - } - - await prisma.post.delete({ where: { id: post.id } }); - reply.status(204).send(); - } - ); - - app.delete<{ Params: { id: string } }>( - "/admin/comments/:id", - { preHandler: [app.requireAdmin] }, + app.post<{ Params: { id: string }; Body: z.infer }>( + "/admin/comments/:id/rollback", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => { const comment = await prisma.comment.findUnique({ where: { id: req.params.id } }); if (!comment) { @@ -166,8 +561,284 @@ export default async function adminPostRoutes(app: FastifyInstance) { return; } - await prisma.comment.delete({ where: { id: comment.id } }); + const { editHistoryId } = rollbackBody.parse(req.body); + const edit = await prisma.editHistory.findUnique({ where: { id: editHistoryId } }); + if (!edit || edit.commentId !== comment.id) { + reply.status(404).send({ error: "Edit history entry not found" }); + return; + } + + const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } }); + if (!admin?.linkedUserId) { + reply.status(400).send({ error: "Admin account must be linked to a user" }); + return; + } + + await prisma.editHistory.create({ + data: { + commentId: comment.id, + editedBy: admin.linkedUserId, + previousBody: comment.body, + }, + }); + + if (edit.previousBody === null) { + reply.status(400).send({ error: "No previous body to restore" }); + return; + } + + const updated = await prisma.comment.update({ + where: { id: comment.id }, + data: { body: edit.previousBody }, + }); + + reply.send(updated); + } + ); + + app.delete<{ Params: { id: string } }>( + "/admin/posts/:id", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post) { + reply.status(404).send({ error: "Post not found" }); + return; + } + + const commentIds = (await prisma.comment.findMany({ + where: { postId: post.id }, + select: { id: true }, + })).map((c) => c.id); + + const attachments = await prisma.attachment.findMany({ + where: { + OR: [ + { postId: post.id }, + ...(commentIds.length ? [{ commentId: { in: commentIds } }] : []), + ], + }, + select: { id: true, path: true }, + }); + + if (attachments.length) { + await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } }); + } + + await prisma.postMerge.deleteMany({ + where: { OR: [{ sourcePostId: post.id }, { targetPostId: post.id }] }, + }); + + await prisma.post.delete({ where: { id: post.id } }); + + const uploadDir = resolve(process.cwd(), "uploads"); + for (const att of attachments) { + await unlink(resolve(uploadDir, att.path)).catch(() => {}); + } + + req.log.info({ adminId: req.adminId, postId: post.id }, "admin post deleted"); reply.status(204).send(); } ); + + app.delete<{ Params: { id: string } }>( + "/admin/comments/:id", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const comment = await prisma.comment.findUnique({ where: { id: req.params.id } }); + if (!comment) { + reply.status(404).send({ error: "Comment not found" }); + return; + } + + const attachments = await prisma.attachment.findMany({ + where: { commentId: comment.id }, + select: { id: true, path: true }, + }); + + if (attachments.length) { + await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } }); + } + + await prisma.comment.delete({ where: { id: comment.id } }); + + const uploadDir = resolve(process.cwd(), "uploads"); + for (const att of attachments) { + await unlink(resolve(uploadDir, att.path)).catch(() => {}); + } + + req.log.info({ adminId: req.adminId, commentId: comment.id }, "admin comment deleted"); + reply.status(204).send(); + } + ); + + app.post<{ Body: z.infer }>( + "/admin/posts/bulk-status", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (req, reply) => { + const parsed = bulkStatusBody.safeParse(req.body); + if (!parsed.success) { + reply.status(400).send({ error: "Invalid request body" }); + return; + } + const { postIds, status } = parsed.data; + + const posts = await prisma.post.findMany({ + where: { id: { in: postIds } }, + select: { id: true, status: true, boardId: true }, + }); + + if (posts.length === 0) { + reply.send({ updated: 0 }); + return; + } + + // validate target status against each board's config + const boardIds = [...new Set(posts.map((p) => p.boardId))]; + for (const boardId of boardIds) { + const boardStatuses = await prisma.boardStatus.findMany({ + where: { boardId, enabled: true }, + }); + if (boardStatuses.length > 0 && !boardStatuses.some((s) => s.status === status)) { + reply.status(400).send({ error: `Status "${status}" is not enabled for board ${boardId}` }); + return; + } + } + + await prisma.$transaction([ + prisma.post.updateMany({ + where: { id: { in: posts.map((p) => p.id) } }, + data: { status, lastActivityAt: new Date() }, + }), + ...posts.map((p) => + prisma.statusChange.create({ + data: { + postId: p.id, + fromStatus: p.status, + toStatus: status, + changedBy: req.adminId!, + }, + }) + ), + ]); + + reply.send({ updated: posts.length }); + } + ); + + app.post<{ Body: z.infer }>( + "/admin/posts/bulk-delete", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 5, timeWindow: "1 minute" } } }, + async (req, reply) => { + const parsed = bulkDeleteBody.safeParse(req.body); + if (!parsed.success) { + reply.status(400).send({ error: "Invalid request body" }); + return; + } + const { postIds } = parsed.data; + + const posts = await prisma.post.findMany({ + where: { id: { in: postIds } }, + select: { id: true, boardId: true, title: true }, + }); + + if (posts.length === 0) { + reply.send({ deleted: 0 }); + return; + } + + const validPostIds = posts.map((p) => p.id); + const commentIds = (await prisma.comment.findMany({ + where: { postId: { in: validPostIds } }, + select: { id: true }, + })).map((c) => c.id); + + const attachments = await prisma.attachment.findMany({ + where: { + OR: [ + { postId: { in: validPostIds } }, + ...(commentIds.length ? [{ commentId: { in: commentIds } }] : []), + ], + }, + select: { id: true, path: true }, + }); + + if (attachments.length) { + await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } }); + } + + await prisma.$transaction([ + prisma.post.deleteMany({ where: { id: { in: validPostIds } } }), + ...posts.map((p) => + prisma.activityEvent.create({ + data: { + type: "post_deleted", + boardId: p.boardId, + postId: p.id, + metadata: { title: p.title, deletedBy: req.adminId }, + }, + }) + ), + ]); + + const uploadDir = resolve(process.cwd(), "uploads"); + for (const att of attachments) { + await unlink(resolve(uploadDir, att.path)).catch(() => {}); + } + + req.log.info({ adminId: req.adminId, count: posts.length }, "bulk posts deleted"); + reply.send({ deleted: posts.length }); + } + ); + + app.post<{ Body: z.infer }>( + "/admin/posts/bulk-tag", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (req, reply) => { + const parsed = bulkTagBody.safeParse(req.body); + if (!parsed.success) { + reply.status(400).send({ error: "Invalid request body" }); + return; + } + const { postIds, tagId, action } = parsed.data; + + const tag = await prisma.tag.findUnique({ where: { id: tagId } }); + if (!tag) { + reply.status(404).send({ error: "Tag not found" }); + return; + } + + const existingPosts = await prisma.post.findMany({ + where: { id: { in: postIds } }, + select: { id: true }, + }); + const validIds = existingPosts.map((p) => p.id); + + if (validIds.length === 0) { + reply.send({ affected: 0 }); + return; + } + + if (action === 'add') { + const existing = await prisma.postTag.findMany({ + where: { tagId, postId: { in: validIds } }, + select: { postId: true }, + }); + const alreadyTagged = new Set(existing.map((e) => e.postId)); + const toAdd = validIds.filter((id) => !alreadyTagged.has(id)); + + if (toAdd.length > 0) { + await prisma.postTag.createMany({ + data: toAdd.map((postId) => ({ postId, tagId })), + }); + } + reply.send({ affected: toAdd.length }); + } else { + const result = await prisma.postTag.deleteMany({ + where: { tagId, postId: { in: validIds } }, + }); + reply.send({ affected: result.count }); + } + } + ); } diff --git a/packages/api/src/routes/admin/settings.ts b/packages/api/src/routes/admin/settings.ts new file mode 100644 index 0000000..aba94b1 --- /dev/null +++ b/packages/api/src/routes/admin/settings.ts @@ -0,0 +1,59 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { prisma } from "../../lib/prisma.js"; + +const HEX_COLOR = /^#[0-9a-fA-F]{6}$/; + +const updateSchema = z.object({ + appName: z.string().min(1).max(100).optional(), + logoUrl: z.string().url().max(500).nullable().optional(), + faviconUrl: z.string().url().max(500).nullable().optional(), + accentColor: z.string().regex(HEX_COLOR).optional(), + headerFont: z.string().max(100).nullable().optional(), + bodyFont: z.string().max(100).nullable().optional(), + poweredByVisible: z.boolean().optional(), + customCss: z.string().max(10000).nullable().optional(), +}); + +export default async function settingsRoutes(app: FastifyInstance) { + app.get( + "/site-settings", + { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, + async (_req, reply) => { + const settings = await prisma.siteSettings.findUnique({ where: { id: "default" } }); + reply.header("Cache-Control", "public, max-age=60"); + reply.send(settings ?? { + appName: "Echoboard", + accentColor: "#F59E0B", + poweredByVisible: true, + }); + } + ); + + app.put( + "/admin/site-settings", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (req, reply) => { + const body = updateSchema.parse(req.body); + + if (body.customCss) { + // decode CSS escape sequences before checking so \75\72\6c() can't bypass url() detection + const decoded = body.customCss + .replace(/\\([0-9a-fA-F]{1,6})\s?/g, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16))) + .replace(/\\(.)/g, '$1'); + const forbidden = /@import|@font-face|@charset|@namespace|url\s*\(|expression\s*\(|javascript:|behavior\s*:|binding\s*:|-moz-binding\s*:/i; + if (forbidden.test(decoded)) { + reply.status(400).send({ error: "Custom CSS contains forbidden patterns" }); + return; + } + } + + const settings = await prisma.siteSettings.upsert({ + where: { id: "default" }, + create: { id: "default", ...body }, + update: body, + }); + reply.send(settings); + } + ); +} diff --git a/packages/api/src/routes/admin/stats.ts b/packages/api/src/routes/admin/stats.ts index b1ad6f4..557bd14 100644 --- a/packages/api/src/routes/admin/stats.ts +++ b/packages/api/src/routes/admin/stats.ts @@ -1,27 +1,32 @@ import { FastifyInstance } from "fastify"; -import { PrismaClient } from "@prisma/client"; import { config } from "../../config.js"; - -const prisma = new PrismaClient(); +import { prisma } from "../../lib/prisma.js"; export default async function adminStatsRoutes(app: FastifyInstance) { app.get( "/admin/stats", - { preHandler: [app.requireAdmin] }, + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (_req, reply) => { + const weekAgo = new Date(); + weekAgo.setDate(weekAgo.getDate() - 7); + const [ totalPosts, totalUsers, totalComments, totalVotes, + thisWeek, postsByStatus, postsByType, boardStats, + topUnresolved, + usersByAuth, ] = await Promise.all([ prisma.post.count(), prisma.user.count(), prisma.comment.count(), prisma.vote.count(), + prisma.post.count({ where: { createdAt: { gte: weekAgo } } }), prisma.post.groupBy({ by: ["status"], _count: true }), prisma.post.groupBy({ by: ["type"], _count: true }), prisma.board.findMany({ @@ -32,30 +37,51 @@ export default async function adminStatsRoutes(app: FastifyInstance) { _count: { select: { posts: true } }, }, }), + prisma.post.findMany({ + where: { status: { in: ["OPEN", "UNDER_REVIEW", "PLANNED", "IN_PROGRESS"] } }, + orderBy: { voteCount: "desc" }, + take: 10, + select: { + id: true, + title: true, + voteCount: true, + board: { select: { slug: true } }, + }, + }), + prisma.user.groupBy({ by: ["authMethod"], _count: true }), ]); reply.send({ + totalPosts, + thisWeek, + byStatus: Object.fromEntries(postsByStatus.map((s) => [s.status, s._count])), + byType: Object.fromEntries(postsByType.map((t) => [t.type, t._count])), + topUnresolved: topUnresolved.map((p) => ({ + id: p.id, + title: p.title, + voteCount: p.voteCount, + boardSlug: p.board.slug, + })), totals: { posts: totalPosts, users: totalUsers, comments: totalComments, votes: totalVotes, }, - postsByStatus: Object.fromEntries(postsByStatus.map((s) => [s.status, s._count])), - postsByType: Object.fromEntries(postsByType.map((t) => [t.type, t._count])), boards: boardStats.map((b) => ({ id: b.id, slug: b.slug, name: b.name, postCount: b._count.posts, })), + authMethodRatio: Object.fromEntries(usersByAuth.map((u) => [u.authMethod, u._count])), }); } ); app.get( "/admin/data-retention", - { preHandler: [app.requireAdmin] }, + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (_req, reply) => { const activityCutoff = new Date(); activityCutoff.setDate(activityCutoff.getDate() - config.DATA_RETENTION_ACTIVITY_DAYS); diff --git a/packages/api/src/routes/admin/statuses.ts b/packages/api/src/routes/admin/statuses.ts new file mode 100644 index 0000000..e7adff1 --- /dev/null +++ b/packages/api/src/routes/admin/statuses.ts @@ -0,0 +1,235 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { prisma } from "../../lib/prisma.js"; + +const DEFAULT_STATUSES = [ + { status: "OPEN", label: "Open", color: "#F59E0B", position: 0 }, + { status: "UNDER_REVIEW", label: "Under Review", color: "#06B6D4", position: 1 }, + { status: "PLANNED", label: "Planned", color: "#3B82F6", position: 2 }, + { status: "IN_PROGRESS", label: "In Progress", color: "#EAB308", position: 3 }, + { status: "DONE", label: "Done", color: "#22C55E", position: 4 }, + { status: "DECLINED", label: "Declined", color: "#EF4444", position: 5 }, +]; + +const statusEntry = z.object({ + status: z.string().min(1).max(50).trim(), + label: z.string().min(1).max(40).trim(), + color: z.string().regex(/^#[0-9a-fA-F]{6}$/), + position: z.number().int().min(0).max(50), +}); + +const bulkUpdateBody = z.object({ + statuses: z.array(statusEntry).min(1).max(50), +}); + +const movePostsBody = z.object({ + fromStatus: z.string().min(1).max(50), + toStatus: z.string().min(1).max(50), +}); + +export default async function adminStatusRoutes(app: FastifyInstance) { + app.get<{ Params: { boardId: string } }>( + "/admin/boards/:boardId/statuses", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { id: req.params.boardId } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + + const existing = await prisma.boardStatus.findMany({ + where: { boardId: board.id, enabled: true }, + orderBy: { position: "asc" }, + }); + + if (existing.length === 0) { + reply.send({ + statuses: DEFAULT_STATUSES.map((s) => ({ + ...s, + enabled: true, + isDefault: true, + })), + }); + return; + } + + reply.send({ + statuses: existing.map((s) => ({ + status: s.status, + label: s.label, + color: s.color, + position: s.position, + enabled: true, + })), + }); + } + ); + + app.put<{ Params: { boardId: string }; Body: z.infer }>( + "/admin/boards/:boardId/statuses", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { id: req.params.boardId } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + + const { statuses } = bulkUpdateBody.parse(req.body); + + const seen = new Set(); + for (const s of statuses) { + if (seen.has(s.status)) { + reply.status(400).send({ error: `Duplicate status: ${s.status}` }); + return; + } + seen.add(s.status); + } + + if (!statuses.some((s) => s.status === "OPEN")) { + reply.status(400).send({ error: "OPEN status must be included" }); + return; + } + + // find currently active statuses being removed + const sentStatuses = new Set(statuses.map((s) => s.status)); + const currentActive = await prisma.boardStatus.findMany({ + where: { boardId: board.id, enabled: true }, + }); + const removedStatuses = currentActive + .map((s) => s.status) + .filter((s) => !sentStatuses.has(s)); + + if (removedStatuses.length > 0) { + const inUse = await prisma.post.count({ + where: { boardId: board.id, status: { in: removedStatuses } }, + }); + if (inUse > 0) { + reply.status(409).send({ + error: "Cannot remove statuses that have posts. Move them first.", + inUseStatuses: removedStatuses, + }); + return; + } + } + + // upsert active, disable removed + const ops = [ + ...statuses.map((s) => + prisma.boardStatus.upsert({ + where: { boardId_status: { boardId: board.id, status: s.status } }, + create: { + boardId: board.id, + status: s.status, + label: s.label, + color: s.color, + position: s.position, + enabled: true, + }, + update: { + label: s.label, + color: s.color, + position: s.position, + enabled: true, + }, + }) + ), + // disable any removed statuses + ...(removedStatuses.length > 0 + ? [prisma.boardStatus.updateMany({ + where: { boardId: board.id, status: { in: removedStatuses } }, + data: { enabled: false }, + })] + : []), + ]; + + await prisma.$transaction(ops); + + const result = await prisma.boardStatus.findMany({ + where: { boardId: board.id, enabled: true }, + orderBy: { position: "asc" }, + }); + + reply.send({ + statuses: result.map((s) => ({ + status: s.status, + label: s.label, + color: s.color, + position: s.position, + enabled: true, + })), + }); + } + ); + + // Move all posts from one status to another on a board + app.post<{ Params: { boardId: string }; Body: z.infer }>( + "/admin/boards/:boardId/statuses/move", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { id: req.params.boardId } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + + const { fromStatus, toStatus } = movePostsBody.parse(req.body); + + if (fromStatus === toStatus) { + reply.status(400).send({ error: "Source and target status must differ" }); + return; + } + + // validate that toStatus is enabled for this board + const boardStatuses = await prisma.boardStatus.findMany({ + where: { boardId: board.id, enabled: true }, + }); + if (boardStatuses.length > 0 && !boardStatuses.some((s) => s.status === toStatus)) { + reply.status(400).send({ error: `Target status "${toStatus}" is not enabled for this board` }); + return; + } + + // find affected posts first for audit trail + const totalAffected = await prisma.post.count({ + where: { boardId: board.id, status: fromStatus }, + }); + if (totalAffected > 500) { + reply.status(400).send({ error: `Too many posts (${totalAffected}). Move in smaller batches by filtering first.` }); + return; + } + const affected = await prisma.post.findMany({ + where: { boardId: board.id, status: fromStatus }, + select: { id: true }, + take: 500, + }); + + if (affected.length === 0) { + reply.send({ moved: 0 }); + return; + } + + const affectedIds = affected.map((p) => p.id); + await prisma.$transaction([ + prisma.post.updateMany({ + where: { id: { in: affectedIds } }, + data: { status: toStatus }, + }), + ...affectedIds.map((postId) => + prisma.statusChange.create({ + data: { + postId, + fromStatus, + toStatus, + changedBy: req.adminId!, + reason: "Bulk status move", + }, + }) + ), + ]); + + req.log.info({ adminId: req.adminId, boardId: board.id, fromStatus, toStatus, count: affectedIds.length }, "bulk status move"); + reply.send({ moved: affectedIds.length }); + } + ); +} diff --git a/packages/api/src/routes/admin/tags.ts b/packages/api/src/routes/admin/tags.ts new file mode 100644 index 0000000..b93c38f --- /dev/null +++ b/packages/api/src/routes/admin/tags.ts @@ -0,0 +1,137 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { prisma } from "../../lib/prisma.js"; + +const createTagBody = z.object({ + name: z.string().min(1).max(30).trim(), + color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#6366F1"), +}); + +const updateTagBody = z.object({ + name: z.string().min(1).max(30).trim().optional(), + color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(), +}); + +const tagPostBody = z.object({ + tagIds: z.array(z.string().min(1)).max(10), +}); + +export default async function adminTagRoutes(app: FastifyInstance) { + app.get( + "/admin/tags", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, + async (_req, reply) => { + const tags = await prisma.tag.findMany({ + orderBy: { name: "asc" }, + include: { _count: { select: { posts: true } } }, + take: 500, + }); + reply.send({ tags }); + } + ); + + app.post<{ Body: z.infer }>( + "/admin/tags", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const body = createTagBody.parse(req.body); + + try { + const tag = await prisma.tag.create({ data: body }); + req.log.info({ adminId: req.adminId, tagId: tag.id, tagName: tag.name }, "tag created"); + reply.status(201).send(tag); + } catch (err: any) { + if (err.code === "P2002") { + reply.status(409).send({ error: "Tag already exists" }); + return; + } + throw err; + } + } + ); + + app.put<{ Params: { id: string }; Body: z.infer }>( + "/admin/tags/:id", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const tag = await prisma.tag.findUnique({ where: { id: req.params.id } }); + if (!tag) { + reply.status(404).send({ error: "Tag not found" }); + return; + } + + const body = updateTagBody.parse(req.body); + + try { + const updated = await prisma.tag.update({ + where: { id: tag.id }, + data: { + ...(body.name !== undefined && { name: body.name }), + ...(body.color !== undefined && { color: body.color }), + }, + }); + req.log.info({ adminId: req.adminId, tagId: tag.id }, "tag updated"); + reply.send(updated); + } catch (err: any) { + if (err.code === "P2002") { + reply.status(409).send({ error: "Tag name already taken" }); + return; + } + throw err; + } + } + ); + + app.delete<{ Params: { id: string } }>( + "/admin/tags/:id", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const tag = await prisma.tag.findUnique({ where: { id: req.params.id } }); + if (!tag) { + reply.status(404).send({ error: "Tag not found" }); + return; + } + + await prisma.tag.delete({ where: { id: tag.id } }); + req.log.info({ adminId: req.adminId, tagId: tag.id, tagName: tag.name }, "tag deleted"); + reply.status(204).send(); + } + ); + + // Set tags on a post (replaces existing tags) + app.put<{ Params: { id: string }; Body: z.infer }>( + "/admin/posts/:id/tags", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post) { + reply.status(404).send({ error: "Post not found" }); + return; + } + + const { tagIds } = tagPostBody.parse(req.body); + + // verify all tags exist + const tags = await prisma.tag.findMany({ where: { id: { in: tagIds } } }); + if (tags.length !== tagIds.length) { + reply.status(400).send({ error: "One or more tags not found" }); + return; + } + + // replace all tags in a transaction + await prisma.$transaction([ + prisma.postTag.deleteMany({ where: { postId: post.id } }), + ...tagIds.map((tagId) => + prisma.postTag.create({ data: { postId: post.id, tagId } }) + ), + ]); + + const updated = await prisma.postTag.findMany({ + where: { postId: post.id }, + include: { tag: true }, + }); + + reply.send({ tags: updated.map((pt) => pt.tag) }); + } + ); +} diff --git a/packages/api/src/routes/admin/team.ts b/packages/api/src/routes/admin/team.ts new file mode 100644 index 0000000..67c6cfd --- /dev/null +++ b/packages/api/src/routes/admin/team.ts @@ -0,0 +1,358 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { randomBytes } from "node:crypto"; +import bcrypt from "bcrypt"; +import jwt from "jsonwebtoken"; +import { prisma } from "../../lib/prisma.js"; +import { config, masterKey, blindIndexKey } from "../../config.js"; +import { encrypt, decrypt, hashToken, blindIndex } from "../../services/encryption.js"; +import { generateRecoveryPhrase } from "../../lib/wordlist.js"; + +function decryptSafe(v: string | null): string | null { + if (!v) return null; + try { return decrypt(v, masterKey); } catch { return null; } +} + +const inviteBody = z.object({ + role: z.enum(["ADMIN", "MODERATOR"]), + expiresInHours: z.number().int().min(1).max(168).default(72), + label: z.string().min(1).max(100).optional(), + generateRecovery: z.boolean().default(false), +}); + +const claimBody = z.object({ + displayName: z.string().min(1).max(100).trim(), + teamTitle: z.string().max(100).trim().optional(), +}); + +const updateProfileBody = z.object({ + displayName: z.string().min(1).max(100).trim().optional(), + teamTitle: z.string().max(100).trim().optional(), +}); + +export default async function adminTeamRoutes(app: FastifyInstance) { + // list team members + app.get( + "/admin/team", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, + async (req, reply) => { + const where = req.adminRole === "SUPER_ADMIN" ? {} : { invitedById: req.adminId }; + const members = await prisma.adminUser.findMany({ + where, + include: { + linkedUser: { select: { id: true, authMethod: true, avatarPath: true } }, + invitedBy: { select: { id: true, displayName: true } }, + _count: { select: { invitees: true } }, + }, + orderBy: { createdAt: "asc" }, + }); + + reply.send({ + members: members.map((m) => ({ + id: m.id, + role: m.role, + displayName: decryptSafe(m.displayName), + teamTitle: decryptSafe(m.teamTitle), + email: m.email ?? null, + hasPasskey: m.linkedUser?.authMethod === "PASSKEY", + avatarUrl: m.linkedUser?.avatarPath ? `/api/v1/avatars/${m.linkedUser.id}` : null, + invitedBy: m.invitedBy ? { id: m.invitedBy.id, displayName: decryptSafe(m.invitedBy.displayName) } : null, + inviteeCount: m._count.invitees, + createdAt: m.createdAt, + })), + }); + } + ); + + // generate invite + app.post( + "/admin/team/invite", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } }, + async (req, reply) => { + const body = inviteBody.parse(req.body); + + // admins can only invite moderators + if (req.adminRole === "ADMIN" && body.role !== "MODERATOR") { + reply.status(403).send({ error: "Admins can only invite moderators" }); + return; + } + + const token = randomBytes(32).toString("hex"); + const tokenH = hashToken(token); + const expiresAt = new Date(Date.now() + body.expiresInHours * 60 * 60 * 1000); + + let recoveryPhrase: string | null = null; + let recoveryHash: string | null = null; + let recoveryIdx: string | null = null; + + if (body.generateRecovery) { + recoveryPhrase = generateRecoveryPhrase(); + recoveryHash = await bcrypt.hash(recoveryPhrase, 12); + recoveryIdx = blindIndex(recoveryPhrase, blindIndexKey); + } + + await prisma.teamInvite.create({ + data: { + tokenHash: tokenH, + role: body.role, + label: body.label ?? null, + expiresAt, + createdById: req.adminId!, + recoveryHash, + recoveryIdx, + }, + }); + + const inviteUrl = `${config.WEBAUTHN_ORIGIN}/admin/join/${token}`; + reply.status(201).send({ inviteUrl, token, recoveryPhrase, expiresAt }); + } + ); + + // list pending invites + app.get( + "/admin/team/invites", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, + async (req, reply) => { + const where: Record = { claimedAt: null, expiresAt: { gt: new Date() } }; + if (req.adminRole !== "SUPER_ADMIN") where.createdById = req.adminId; + + const invites = await prisma.teamInvite.findMany({ + where, + include: { createdBy: { select: { id: true, displayName: true } } }, + orderBy: { createdAt: "desc" }, + }); + + reply.send({ + invites: invites.map((inv) => ({ + id: inv.id, + role: inv.role, + label: inv.label, + expiresAt: inv.expiresAt, + hasRecovery: !!inv.recoveryHash, + createdBy: { id: inv.createdBy.id, displayName: decryptSafe(inv.createdBy.displayName) }, + createdAt: inv.createdAt, + })), + }); + } + ); + + // revoke invite + app.delete<{ Params: { id: string } }>( + "/admin/team/invites/:id", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const invite = await prisma.teamInvite.findUnique({ where: { id: req.params.id } }); + if (!invite || invite.claimedAt) { + reply.status(404).send({ error: "Invite not found" }); + return; + } + if (req.adminRole !== "SUPER_ADMIN" && invite.createdById !== req.adminId) { + reply.status(403).send({ error: "Not your invite" }); + return; + } + await prisma.teamInvite.delete({ where: { id: invite.id } }); + reply.status(204).send(); + } + ); + + // remove team member (super admin only) + app.delete<{ Params: { id: string } }>( + "/admin/team/:id", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (req, reply) => { + if (req.params.id === req.adminId) { + reply.status(400).send({ error: "Cannot remove yourself" }); + return; + } + const member = await prisma.adminUser.findUnique({ where: { id: req.params.id } }); + if (!member) { + reply.status(404).send({ error: "Team member not found" }); + return; + } + if (member.role === "SUPER_ADMIN") { + reply.status(403).send({ error: "Cannot remove the super admin" }); + return; + } + await prisma.adminUser.delete({ where: { id: member.id } }); + reply.status(204).send(); + } + ); + + // update own profile + app.put( + "/admin/team/me", + { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const body = updateProfileBody.parse(req.body); + const data: Record = {}; + if (body.displayName !== undefined) data.displayName = encrypt(body.displayName, masterKey); + if (body.teamTitle !== undefined) data.teamTitle = body.teamTitle ? encrypt(body.teamTitle, masterKey) : ""; + + if (Object.keys(data).length === 0) { + reply.status(400).send({ error: "Nothing to update" }); + return; + } + + await prisma.adminUser.update({ where: { id: req.adminId! }, data }); + reply.send({ ok: true }); + } + ); + + // regenerate recovery phrase for a team member + app.post<{ Params: { id: string } }>( + "/admin/team/:id/recovery", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 5, timeWindow: "1 hour" } } }, + async (req, reply) => { + const target = await prisma.adminUser.findUnique({ + where: { id: req.params.id }, + select: { id: true, role: true, invitedById: true, linkedUserId: true }, + }); + if (!target || !target.linkedUserId) { + reply.status(404).send({ error: "Team member not found" }); + return; + } + if (target.role === "SUPER_ADMIN") { + reply.status(403).send({ error: "Cannot regenerate recovery for super admin" }); + return; + } + // admins can only regenerate for people they invited + if (req.adminRole === "ADMIN" && target.invitedById !== req.adminId) { + reply.status(403).send({ error: "You can only regenerate recovery for people you invited" }); + return; + } + + const phrase = generateRecoveryPhrase(); + const codeHash = await bcrypt.hash(phrase, 12); + const phraseIdx = blindIndex(phrase, blindIndexKey); + const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); + + await prisma.recoveryCode.upsert({ + where: { userId: target.linkedUserId }, + create: { codeHash, phraseIdx, userId: target.linkedUserId, expiresAt }, + update: { codeHash, phraseIdx, expiresAt }, + }); + + reply.send({ phrase, expiresAt }); + } + ); + + // validate invite token (public, for the claim page) + app.get<{ Params: { token: string } }>( + "/admin/join/:token", + { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (req, reply) => { + const tokenH = hashToken(req.params.token); + const invite = await prisma.teamInvite.findUnique({ + where: { tokenHash: tokenH }, + include: { createdBy: { select: { displayName: true } } }, + }); + if (!invite || invite.claimedAt || invite.expiresAt < new Date()) { + reply.status(404).send({ error: "Invalid or expired invite" }); + return; + } + reply.send({ + role: invite.role, + label: invite.label, + invitedBy: decryptSafe(invite.createdBy.displayName) ?? "Admin", + expiresAt: invite.expiresAt, + hasRecovery: !!invite.recoveryHash, + }); + } + ); + + // claim invite + app.post<{ Params: { token: string } }>( + "/admin/join/:token", + { config: { rateLimit: { max: 5, timeWindow: "15 minutes" } } }, + async (req, reply) => { + const body = claimBody.parse(req.body); + const tokenH = hashToken(req.params.token); + + const sessionToken = randomBytes(32).toString("hex"); + const sessionHash = hashToken(sessionToken); + const encDisplayName = encrypt(body.displayName, masterKey); + const encTeamTitle = body.teamTitle ? encrypt(body.teamTitle, masterKey) : null; + + let result; + try { + result = await prisma.$transaction(async (tx) => { + const invite = await tx.teamInvite.findUnique({ where: { tokenHash: tokenH } }); + if (!invite || invite.claimedAt || invite.expiresAt < new Date()) { + throw new Error("INVITE_INVALID"); + } + + const user = await tx.user.create({ + data: { + displayName: encDisplayName, + authMethod: "COOKIE", + tokenHash: sessionHash, + }, + }); + + const admin = await tx.adminUser.create({ + data: { + role: invite.role, + displayName: encDisplayName, + teamTitle: encTeamTitle, + linkedUserId: user.id, + invitedById: invite.createdById, + }, + }); + + await tx.teamInvite.update({ + where: { id: invite.id }, + data: { claimedById: admin.id, claimedAt: new Date() }, + }); + + if (invite.recoveryHash && invite.recoveryIdx) { + await tx.recoveryCode.create({ + data: { + codeHash: invite.recoveryHash, + phraseIdx: invite.recoveryIdx, + userId: user.id, + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + }, + }); + } + + return { userId: user.id, adminId: admin.id, hasRecovery: !!invite.recoveryHash }; + }, { isolationLevel: "Serializable" }); + } catch (err: any) { + if (err.message === "INVITE_INVALID") { + reply.status(404).send({ error: "Invalid or expired invite" }); + return; + } + throw err; + } + + const adminJwt = jwt.sign( + { sub: result.adminId, type: "admin", aud: "echoboard:admin", iss: "echoboard" }, + config.JWT_SECRET, + { expiresIn: "24h" } + ); + const userJwt = jwt.sign( + { sub: result.userId, type: "passkey", aud: "echoboard:user", iss: "echoboard" }, + config.JWT_SECRET, + { expiresIn: "24h" } + ); + + reply + .setCookie("echoboard_token", sessionToken, { + path: "/", httpOnly: true, sameSite: "strict", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 24 * 90, + }) + .setCookie("echoboard_admin", adminJwt, { + path: "/", httpOnly: true, sameSite: "strict", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 24, + }) + .setCookie("echoboard_passkey", userJwt, { + path: "/", httpOnly: true, sameSite: "strict", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 24, + }) + .send({ ok: true, needsSetup: !result.hasRecovery }); + } + ); +} diff --git a/packages/api/src/routes/admin/templates.ts b/packages/api/src/routes/admin/templates.ts new file mode 100644 index 0000000..f1c7c54 --- /dev/null +++ b/packages/api/src/routes/admin/templates.ts @@ -0,0 +1,125 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { prisma } from "../../lib/prisma.js"; + +const fieldSchema = z.object({ + key: z.string().min(1).max(50).regex(/^[a-zA-Z_][a-zA-Z0-9_ ]*$/, "Invalid field key"), + label: z.string().min(1).max(100), + type: z.enum(["text", "textarea", "select"]), + required: z.boolean(), + placeholder: z.string().max(200).optional(), + options: z.array(z.string().max(100)).optional(), +}); + +const createBody = z.object({ + name: z.string().min(1).max(100).trim(), + fields: z.array(fieldSchema).min(1).max(30), + isDefault: z.boolean().default(false), +}); + +const updateBody = z.object({ + name: z.string().min(1).max(100).trim().optional(), + fields: z.array(fieldSchema).min(1).max(30).optional(), + isDefault: z.boolean().optional(), + position: z.number().int().min(0).optional(), +}); + +export default async function adminTemplateRoutes(app: FastifyInstance) { + app.get<{ Params: { boardId: string } }>( + "/admin/boards/:boardId/templates", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { id: req.params.boardId } }); + if (!board) return reply.status(404).send({ error: "Board not found" }); + + const templates = await prisma.boardTemplate.findMany({ + where: { boardId: board.id }, + orderBy: { position: "asc" }, + }); + + reply.send({ templates }); + } + ); + + app.post<{ Params: { boardId: string }; Body: z.infer }>( + "/admin/boards/:boardId/templates", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { id: req.params.boardId } }); + if (!board) return reply.status(404).send({ error: "Board not found" }); + + const body = createBody.parse(req.body); + + const maxPos = await prisma.boardTemplate.aggregate({ + where: { boardId: board.id }, + _max: { position: true }, + }); + const nextPos = (maxPos._max.position ?? -1) + 1; + + // if setting as default, unset others + if (body.isDefault) { + await prisma.boardTemplate.updateMany({ + where: { boardId: board.id, isDefault: true }, + data: { isDefault: false }, + }); + } + + const template = await prisma.boardTemplate.create({ + data: { + boardId: board.id, + name: body.name, + fields: body.fields as any, + isDefault: body.isDefault, + position: nextPos, + }, + }); + + req.log.info({ adminId: req.adminId, templateId: template.id, boardId: board.id }, "template created"); + reply.status(201).send(template); + } + ); + + app.put<{ Params: { id: string }; Body: z.infer }>( + "/admin/templates/:id", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const existing = await prisma.boardTemplate.findUnique({ where: { id: req.params.id } }); + if (!existing) return reply.status(404).send({ error: "Template not found" }); + + const body = updateBody.parse(req.body); + + if (body.isDefault) { + await prisma.boardTemplate.updateMany({ + where: { boardId: existing.boardId, isDefault: true, id: { not: existing.id } }, + data: { isDefault: false }, + }); + } + + const updated = await prisma.boardTemplate.update({ + where: { id: existing.id }, + data: { + ...(body.name !== undefined && { name: body.name }), + ...(body.fields !== undefined && { fields: body.fields as any }), + ...(body.isDefault !== undefined && { isDefault: body.isDefault }), + ...(body.position !== undefined && { position: body.position }), + }, + }); + + req.log.info({ adminId: req.adminId, templateId: existing.id }, "template updated"); + reply.send(updated); + } + ); + + app.delete<{ Params: { id: string } }>( + "/admin/templates/:id", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const existing = await prisma.boardTemplate.findUnique({ where: { id: req.params.id } }); + if (!existing) return reply.status(404).send({ error: "Template not found" }); + + await prisma.boardTemplate.delete({ where: { id: existing.id } }); + req.log.info({ adminId: req.adminId, templateId: existing.id, boardId: existing.boardId }, "template deleted"); + reply.status(204).send(); + } + ); +} diff --git a/packages/api/src/routes/admin/webhooks.ts b/packages/api/src/routes/admin/webhooks.ts new file mode 100644 index 0000000..8377e1f --- /dev/null +++ b/packages/api/src/routes/admin/webhooks.ts @@ -0,0 +1,144 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { randomBytes } from "node:crypto"; +import { encrypt } from "../../services/encryption.js"; +import { masterKey } from "../../config.js"; +import { isAllowedUrl, resolvedIpIsAllowed } from "../../services/webhooks.js"; +import { prisma } from "../../lib/prisma.js"; + +const VALID_EVENTS = ["status_changed", "post_created", "comment_added"] as const; + +const httpsUrl = z.string().url().max(500).refine((val) => { + try { + return new URL(val).protocol === "https:"; + } catch { return false; } +}, { message: "Only HTTPS URLs are allowed" }); + +const createBody = z.object({ + url: httpsUrl, + events: z.array(z.enum(VALID_EVENTS)).min(1), +}); + +const updateBody = z.object({ + url: httpsUrl.optional(), + events: z.array(z.enum(VALID_EVENTS)).min(1).optional(), + active: z.boolean().optional(), +}); + +export default async function adminWebhookRoutes(app: FastifyInstance) { + app.get( + "/admin/webhooks", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, + async (_req, reply) => { + const webhooks = await prisma.webhook.findMany({ orderBy: { createdAt: "desc" }, take: 100 }); + reply.send({ + webhooks: webhooks.map((w) => ({ + id: w.id, + url: w.url, + events: w.events, + active: w.active, + createdAt: w.createdAt, + })), + }); + } + ); + + app.post<{ Body: z.infer }>( + "/admin/webhooks", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const body = createBody.parse(req.body); + + if (!isAllowedUrl(body.url)) { + reply.status(400).send({ error: "URL not allowed" }); + return; + } + const createHostname = new URL(body.url).hostname; + if (!await resolvedIpIsAllowed(createHostname)) { + reply.status(400).send({ error: "URL resolves to a disallowed address" }); + return; + } + + const plainSecret = randomBytes(32).toString("hex"); + const encryptedSecret = encrypt(plainSecret, masterKey); + + const webhook = await prisma.webhook.create({ + data: { + url: body.url, + secret: encryptedSecret, + events: body.events, + }, + }); + + req.log.info({ adminId: req.adminId, webhookId: webhook.id }, "webhook created"); + // return plaintext secret only on creation - admin needs it to verify signatures + reply.status(201).send({ + id: webhook.id, + url: webhook.url, + events: webhook.events, + active: webhook.active, + secret: plainSecret, + createdAt: webhook.createdAt, + }); + } + ); + + app.put<{ Params: { id: string }; Body: z.infer }>( + "/admin/webhooks/:id", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const wh = await prisma.webhook.findUnique({ where: { id: req.params.id } }); + if (!wh) { + reply.status(404).send({ error: "Webhook not found" }); + return; + } + + const body = updateBody.parse(req.body); + + if (body.url) { + if (!isAllowedUrl(body.url)) { + reply.status(400).send({ error: "URL not allowed" }); + return; + } + const updateHostname = new URL(body.url).hostname; + if (!await resolvedIpIsAllowed(updateHostname)) { + reply.status(400).send({ error: "URL resolves to a disallowed address" }); + return; + } + } + + const updated = await prisma.webhook.update({ + where: { id: wh.id }, + data: { + ...(body.url !== undefined && { url: body.url }), + ...(body.events !== undefined && { events: body.events }), + ...(body.active !== undefined && { active: body.active }), + }, + }); + req.log.info({ adminId: req.adminId, webhookId: wh.id }, "webhook updated"); + reply.send({ + id: updated.id, + url: updated.url, + events: updated.events, + active: updated.active, + createdAt: updated.createdAt, + }); + } + ); + + app.delete<{ Params: { id: string } }>( + "/admin/webhooks/:id", + { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const wh = await prisma.webhook.findUnique({ where: { id: req.params.id } }); + if (!wh) { + reply.status(404).send({ error: "Webhook not found" }); + return; + } + + await prisma.webhook.delete({ where: { id: wh.id } }); + req.log.info({ adminId: req.adminId, webhookId: wh.id }, "webhook deleted"); + reply.status(204).send(); + } + ); +} diff --git a/packages/api/src/routes/attachments.ts b/packages/api/src/routes/attachments.ts new file mode 100644 index 0000000..d838fb8 --- /dev/null +++ b/packages/api/src/routes/attachments.ts @@ -0,0 +1,170 @@ +import { FastifyInstance } from "fastify"; +import { prisma } from "../lib/prisma.js"; +import { existsSync, mkdirSync, createReadStream } from "node:fs"; +import { unlink, writeFile, realpath } from "node:fs/promises"; +import { resolve, extname, sep } from "node:path"; +import { randomBytes } from "node:crypto"; + +const UPLOAD_DIR = resolve(process.cwd(), "uploads"); +const MAX_SIZE = 5 * 1024 * 1024; +const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]); +const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]); + +const MAGIC_BYTES: Record = { + "image/jpeg": [[0xFF, 0xD8, 0xFF]], + "image/png": [[0x89, 0x50, 0x4E, 0x47]], + "image/gif": [[0x47, 0x49, 0x46, 0x38, 0x37, 0x61], [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]], + "image/webp": [[0x52, 0x49, 0x46, 0x46, -1, -1, -1, -1, 0x57, 0x45, 0x42, 0x50]], +}; + +function validateMagicBytes(buf: Buffer, mime: string): boolean { + const sigs = MAGIC_BYTES[mime]; + if (!sigs) return false; + return sigs.some((sig) => sig.every((byte, i) => byte === -1 || buf[i] === byte)); +} + +function sanitizeFilename(name: string): string { + return name.replace(/[\/\\:*?"<>|\x00-\x1F]/g, "_").slice(0, 200); +} + +function uid() { + return randomBytes(16).toString("hex"); +} + +export default async function attachmentRoutes(app: FastifyInstance) { + if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true }); + + app.post( + "/attachments", + { preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } }, + async (req, reply) => { + const data = await req.file(); + if (!data) { + reply.status(400).send({ error: "No file uploaded" }); + return; + } + + const ext = extname(data.filename).toLowerCase(); + if (!ALLOWED_EXT.has(ext) || !ALLOWED_MIME.has(data.mimetype)) { + reply.status(400).send({ error: "Only jpg, png, gif, webp images are allowed" }); + return; + } + + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of data.file) { + size += chunk.length; + if (size > MAX_SIZE) { + reply.status(400).send({ error: "File too large (max 5MB)" }); + return; + } + chunks.push(chunk); + } + const buffer = Buffer.concat(chunks); + + if (!validateMagicBytes(buffer, data.mimetype)) { + reply.status(400).send({ error: "File content does not match declared type" }); + return; + } + + const safeName = sanitizeFilename(data.filename); + const storedName = `${uid()}${ext}`; + const filePath = resolve(UPLOAD_DIR, storedName); + + if (!filePath.startsWith(UPLOAD_DIR)) { + reply.status(400).send({ error: "Invalid file path" }); + return; + } + + await writeFile(filePath, buffer); + + let attachment; + try { + attachment = await prisma.attachment.create({ + data: { + filename: safeName, + path: storedName, + size, + mimeType: data.mimetype, + uploaderId: req.user!.id, + }, + }); + } catch (err) { + await unlink(filePath).catch(() => {}); + throw err; + } + + reply.status(201).send(attachment); + } + ); + + app.get<{ Params: { id: string } }>( + "/attachments/:id", + { config: { rateLimit: { max: 200, timeWindow: "1 minute" } } }, + async (req, reply) => { + const attachment = await prisma.attachment.findUnique({ where: { id: req.params.id } }); + if (!attachment) { + reply.status(404).send({ error: "Attachment not found" }); + return; + } + + const filePath = resolve(UPLOAD_DIR, attachment.path); + let realFile: string; + try { + realFile = await realpath(filePath); + } catch { + reply.status(404).send({ error: "File missing" }); + return; + } + const realUpload = await realpath(UPLOAD_DIR); + if (!realFile.startsWith(realUpload + sep)) { + reply.status(404).send({ error: "File missing" }); + return; + } + + const safeName = sanitizeFilename(attachment.filename).replace(/"/g, ""); + reply + .header("Content-Type", attachment.mimeType) + .header("Content-Disposition", `inline; filename="${safeName}"`) + .header("Cache-Control", "public, max-age=31536000, immutable") + .header("X-Content-Type-Options", "nosniff") + .send(createReadStream(filePath)); + } + ); + + app.delete<{ Params: { id: string } }>( + "/attachments/:id", + { preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, + async (req, reply) => { + const attachment = await prisma.attachment.findUnique({ where: { id: req.params.id } }); + if (!attachment) { + reply.status(404).send({ error: "Attachment not found" }); + return; + } + + if (attachment.uploaderId !== req.user!.id && !req.adminId) { + reply.status(403).send({ error: "Not your attachment" }); + return; + } + + const filePath = resolve(UPLOAD_DIR, attachment.path); + let realFile: string; + try { + realFile = await realpath(filePath); + } catch { + await prisma.attachment.delete({ where: { id: attachment.id } }); + reply.status(204).send(); + return; + } + const realUpload = await realpath(UPLOAD_DIR); + if (!realFile.startsWith(realUpload + sep)) { + reply.status(400).send({ error: "Invalid file path" }); + return; + } + await unlink(realFile).catch(() => {}); + await prisma.attachment.delete({ where: { id: attachment.id } }); + + reply.status(204).send(); + } + ); +} diff --git a/packages/api/src/routes/avatars.ts b/packages/api/src/routes/avatars.ts new file mode 100644 index 0000000..7cb6ff0 --- /dev/null +++ b/packages/api/src/routes/avatars.ts @@ -0,0 +1,175 @@ +import { FastifyInstance } from "fastify"; +import { existsSync, mkdirSync, createReadStream } from "node:fs"; +import { unlink, writeFile, realpath } from "node:fs/promises"; +import { resolve, extname, sep } from "node:path"; +import { randomBytes } from "node:crypto"; +import prisma from "../lib/prisma.js"; + +const UPLOAD_DIR = resolve(process.cwd(), "uploads", "avatars"); +const MAX_SIZE = 2 * 1024 * 1024; +const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/webp"]); +const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".webp"]); + +const MAGIC_BYTES: Record = { + "image/jpeg": [[0xFF, 0xD8, 0xFF]], + "image/png": [[0x89, 0x50, 0x4E, 0x47]], + "image/webp": [[0x52, 0x49, 0x46, 0x46, -1, -1, -1, -1, 0x57, 0x45, 0x42, 0x50]], +}; + +function validateMagicBytes(buf: Buffer, mime: string): boolean { + const sigs = MAGIC_BYTES[mime]; + if (!sigs) return false; + return sigs.some((sig) => sig.every((byte, i) => byte === -1 || buf[i] === byte)); +} + +export default async function avatarRoutes(app: FastifyInstance) { + if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true }); + + app.post( + "/me/avatar", + { preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } }, + async (req, reply) => { + const user = await prisma.user.findUnique({ where: { id: req.user!.id } }); + if (!user) { + reply.status(403).send({ error: "Not authenticated" }); + return; + } + // allow passkey users and admin-linked users + if (user.authMethod !== "PASSKEY") { + const isTeamMember = await prisma.adminUser.findUnique({ where: { linkedUserId: user.id }, select: { id: true } }); + if (!isTeamMember) { + reply.status(403).send({ error: "Save your identity with a passkey to upload avatars" }); + return; + } + } + + const data = await req.file(); + if (!data) { + reply.status(400).send({ error: "No file uploaded" }); + return; + } + + const ext = extname(data.filename).toLowerCase(); + if (!ALLOWED_EXT.has(ext) || !ALLOWED_MIME.has(data.mimetype)) { + reply.status(400).send({ error: "Only jpg, png, webp images are allowed" }); + return; + } + + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of data.file) { + size += chunk.length; + if (size > MAX_SIZE) { + reply.status(400).send({ error: "File too large (max 2MB)" }); + return; + } + chunks.push(chunk); + } + const buffer = Buffer.concat(chunks); + + if (!validateMagicBytes(buffer, data.mimetype)) { + reply.status(400).send({ error: "File content does not match declared type" }); + return; + } + + // delete old avatar if exists (with traversal check) + if (user.avatarPath) { + const oldPath = resolve(UPLOAD_DIR, user.avatarPath); + try { + const realOld = await realpath(oldPath); + const realUpload = await realpath(UPLOAD_DIR); + if (realOld.startsWith(realUpload + sep)) { + await unlink(realOld).catch(() => {}); + } + } catch {} + } + + const storedName = `${randomBytes(16).toString("hex")}${ext}`; + const filePath = resolve(UPLOAD_DIR, storedName); + + if (!filePath.startsWith(UPLOAD_DIR)) { + reply.status(400).send({ error: "Invalid file path" }); + return; + } + + await writeFile(filePath, buffer); + + await prisma.user.update({ + where: { id: user.id }, + data: { avatarPath: storedName }, + }); + + reply.send({ avatarUrl: `/api/v1/avatars/${user.id}` }); + } + ); + + app.delete( + "/me/avatar", + { preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (req, reply) => { + const user = await prisma.user.findUnique({ where: { id: req.user!.id } }); + if (!user || !user.avatarPath) { + reply.status(204).send(); + return; + } + + const filePath = resolve(UPLOAD_DIR, user.avatarPath); + try { + const realFile = await realpath(filePath); + const realUpload = await realpath(UPLOAD_DIR); + if (realFile.startsWith(realUpload + sep)) { + await unlink(realFile).catch(() => {}); + } + } catch {} + + await prisma.user.update({ + where: { id: user.id }, + data: { avatarPath: null }, + }); + + reply.status(204).send(); + } + ); + + app.get<{ Params: { userId: string } }>( + "/avatars/:userId", + { config: { rateLimit: { max: 200, timeWindow: "1 minute" } } }, + async (req, reply) => { + const user = await prisma.user.findUnique({ + where: { id: req.params.userId }, + select: { avatarPath: true }, + }); + + if (!user?.avatarPath) { + reply.status(404).send({ error: "No avatar" }); + return; + } + + const filePath = resolve(UPLOAD_DIR, user.avatarPath); + let realFile: string; + try { + realFile = await realpath(filePath); + } catch { + reply.status(404).send({ error: "File missing" }); + return; + } + const realUpload = await realpath(UPLOAD_DIR); + if (!realFile.startsWith(realUpload + sep)) { + reply.status(404).send({ error: "File missing" }); + return; + } + + const ext = extname(user.avatarPath).toLowerCase(); + const mimeMap: Record = { + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".png": "image/png", ".webp": "image/webp", + }; + + reply + .header("Content-Type", mimeMap[ext] || "application/octet-stream") + .header("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400") + .header("X-Content-Type-Options", "nosniff") + .send(createReadStream(filePath)); + } + ); +} diff --git a/packages/api/src/routes/boards.ts b/packages/api/src/routes/boards.ts index 9f39ed5..a7a0128 100644 --- a/packages/api/src/routes/boards.ts +++ b/packages/api/src/routes/boards.ts @@ -1,35 +1,103 @@ import { FastifyInstance } from "fastify"; -import { PrismaClient } from "@prisma/client"; +import { z } from "zod"; +import prisma from "../lib/prisma.js"; +import { encrypt, blindIndex } from "../services/encryption.js"; +import { masterKey, blindIndexKey } from "../config.js"; -const prisma = new PrismaClient(); +const PUSH_DOMAINS = [ + "fcm.googleapis.com", "updates.push.services.mozilla.com", + "notify.windows.com", "push.apple.com", "web.push.apple.com", +]; + +function isValidPushEndpoint(url: string): boolean { + try { + const parsed = new URL(url); + if (parsed.protocol !== "https:") return false; + return PUSH_DOMAINS.some((d) => parsed.hostname === d || parsed.hostname.endsWith("." + d)); + } catch { return false; } +} + +const subscribeBody = z.object({ + endpoint: z.string().url().refine(isValidPushEndpoint, "Must be a known push service endpoint"), + keys: z.object({ + p256dh: z.string().min(1).max(200), + auth: z.string().min(1).max(200), + }), +}); + +const DEFAULT_STATUS_CONFIG: { status: string; label: string; color: string; position: number; enabled: boolean }[] = [ + { status: "OPEN", label: "Open", color: "#F59E0B", position: 0, enabled: true }, + { status: "UNDER_REVIEW", label: "Under Review", color: "#06B6D4", position: 1, enabled: true }, + { status: "PLANNED", label: "Planned", color: "#3B82F6", position: 2, enabled: true }, + { status: "IN_PROGRESS", label: "In Progress", color: "#EAB308", position: 3, enabled: true }, + { status: "DONE", label: "Done", color: "#22C55E", position: 4, enabled: true }, + { status: "DECLINED", label: "Declined", color: "#EF4444", position: 5, enabled: true }, +]; + +async function getStatusConfig(boardId: string) { + const config = await prisma.boardStatus.findMany({ + where: { boardId }, + orderBy: { position: "asc" }, + }); + if (config.length === 0) return DEFAULT_STATUS_CONFIG; + return config.map((s) => ({ + status: s.status, + label: s.label, + color: s.color, + position: s.position, + enabled: s.enabled, + })); +} export default async function boardRoutes(app: FastifyInstance) { - app.get("/boards", async (_req, reply) => { + app.get("/boards", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (_req, reply) => { const boards = await prisma.board.findMany({ - where: { isArchived: false }, include: { _count: { select: { posts: true } }, }, orderBy: { createdAt: "asc" }, }); + // use aggregation queries instead of loading all posts into memory + const boardIds = boards.map((b) => b.id); + const [openCounts, lastActivities] = await Promise.all([ + prisma.post.groupBy({ + by: ["boardId"], + where: { boardId: { in: boardIds }, status: { in: ["OPEN", "UNDER_REVIEW"] } }, + _count: true, + }), + prisma.post.groupBy({ + by: ["boardId"], + where: { boardId: { in: boardIds } }, + _max: { updatedAt: true }, + }), + ]); + + const openMap = new Map(openCounts.map((r) => [r.boardId, r._count])); + const activityMap = new Map(lastActivities.map((r) => [r.boardId, r._max.updatedAt])); + const result = boards.map((b) => ({ id: b.id, slug: b.slug, name: b.name, description: b.description, externalUrl: b.externalUrl, + iconName: b.iconName, + iconColor: b.iconColor, voteBudget: b.voteBudget, voteBudgetReset: b.voteBudgetReset, allowMultiVote: b.allowMultiVote, postCount: b._count.posts, + openCount: openMap.get(b.id) ?? 0, + lastActivity: activityMap.get(b.id)?.toISOString() ?? null, + archived: b.isArchived, createdAt: b.createdAt, })); reply.send(result); }); - app.get<{ Params: { boardSlug: string } }>("/boards/:boardSlug", async (req, reply) => { + app.get<{ Params: { boardSlug: string } }>("/boards/:boardSlug", { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (req, reply) => { const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug }, include: { @@ -46,19 +114,99 @@ export default async function boardRoutes(app: FastifyInstance) { return; } + const statuses = await getStatusConfig(board.id); + reply.send({ id: board.id, slug: board.slug, name: board.name, description: board.description, externalUrl: board.externalUrl, + iconName: board.iconName, + iconColor: board.iconColor, isArchived: board.isArchived, voteBudget: board.voteBudget, voteBudgetReset: board.voteBudgetReset, allowMultiVote: board.allowMultiVote, postCount: board._count.posts, + statuses: statuses.filter((s) => s.enabled), createdAt: board.createdAt, updatedAt: board.updatedAt, }); }); + + app.get<{ Params: { boardSlug: string } }>("/boards/:boardSlug/statuses", { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (req, reply) => { + const board = await prisma.board.findUnique({ + where: { slug: req.params.boardSlug }, + }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + + const statuses = await getStatusConfig(board.id); + reply.send({ statuses: statuses.filter((s) => s.enabled) }); + }); + + // Check board subscription status + app.get<{ Params: { slug: string } }>( + "/boards/:slug/subscription", + { preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { slug: req.params.slug } }); + if (!board) return reply.status(404).send({ error: "Board not found" }); + const sub = await prisma.pushSubscription.findFirst({ + where: { userId: req.user!.id, boardId: board.id, postId: null }, + }); + reply.send({ subscribed: !!sub }); + } + ); + + // Subscribe to board + app.post<{ Params: { slug: string } }>( + "/boards/:slug/subscribe", + { preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { slug: req.params.slug } }); + if (!board) return reply.status(404).send({ error: "Board not found" }); + + const body = subscribeBody.parse(req.body); + const endpointEnc = encrypt(body.endpoint, masterKey); + const endpointIdx = blindIndex(body.endpoint, blindIndexKey); + const keysP256dh = encrypt(body.keys.p256dh, masterKey); + const keysAuth = encrypt(body.keys.auth, masterKey); + + await prisma.pushSubscription.upsert({ + where: { endpointIdx }, + create: { + endpoint: endpointEnc, + endpointIdx, + keysP256dh, + keysAuth, + userId: req.user!.id, + boardId: board.id, + postId: null, + }, + update: { keysP256dh, keysAuth, boardId: board.id, postId: null }, + }); + + reply.send({ subscribed: true }); + } + ); + + // Unsubscribe from board + app.delete<{ Params: { slug: string } }>( + "/boards/:slug/subscribe", + { preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { slug: req.params.slug } }); + if (!board) return reply.status(404).send({ error: "Board not found" }); + + await prisma.pushSubscription.deleteMany({ + where: { userId: req.user!.id, boardId: board.id, postId: null }, + }); + + reply.send({ subscribed: false }); + } + ); } diff --git a/packages/api/src/routes/changelog.ts b/packages/api/src/routes/changelog.ts new file mode 100644 index 0000000..12bdc49 --- /dev/null +++ b/packages/api/src/routes/changelog.ts @@ -0,0 +1,26 @@ +import { FastifyInstance } from "fastify"; +import { prisma } from "../lib/prisma.js"; + +export default async function changelogRoutes(app: FastifyInstance) { + const handler = async (req: any, reply: any) => { + const boardSlug = req.params?.boardSlug || (req.query as any)?.board || null; + + const where: any = { publishedAt: { lte: new Date() } }; + if (boardSlug) { + where.board = { slug: boardSlug }; + } + + const entries = await prisma.changelogEntry.findMany({ + where, + include: { board: { select: { slug: true, name: true } } }, + orderBy: { publishedAt: "desc" }, + take: 50, + }); + reply.send({ entries }); + }; + + const opts = { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }; + + app.get("/changelog", opts, handler); + app.get("/b/:boardSlug/changelog", opts, handler); +} diff --git a/packages/api/src/routes/comments.ts b/packages/api/src/routes/comments.ts index 4652105..aef01f9 100644 --- a/packages/api/src/routes/comments.ts +++ b/packages/api/src/routes/comments.ts @@ -1,28 +1,54 @@ import { FastifyInstance } from "fastify"; -import { PrismaClient } from "@prisma/client"; import { z } from "zod"; +import { unlink } from "node:fs/promises"; +import { resolve } from "node:path"; +import prisma from "../lib/prisma.js"; import { verifyChallenge } from "../services/altcha.js"; +import { decrypt, blindIndex } from "../services/encryption.js"; +import { masterKey, blindIndexKey } from "../config.js"; +import { notifyPostSubscribers, notifyUserReply } from "../services/push.js"; -const prisma = new PrismaClient(); +const INVISIBLE_RE = /[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g; + +function cleanAuthor(author: { id: string; displayName: string | null; avatarPath?: string | null } | null) { + if (!author) return null; + let name: string | null = null; + if (author.displayName) { try { name = decrypt(author.displayName, masterKey); } catch {} } + return { + id: author.id, + displayName: name, + avatarUrl: author.avatarPath ? `/api/v1/avatars/${author.id}` : null, + }; +} const createCommentSchema = z.object({ - body: z.string().min(1).max(5000), - altcha: z.string(), + body: z.string().min(1).max(2000), + altcha: z.string().optional(), + replyToId: z.string().max(30).optional(), + attachmentIds: z.array(z.string()).max(10).optional(), }); const updateCommentSchema = z.object({ - body: z.string().min(1).max(5000), + body: z.string().min(1).max(2000), }); export default async function commentRoutes(app: FastifyInstance) { app.get<{ Params: { boardSlug: string; id: string }; Querystring: { page?: string } }>( "/boards/:boardSlug/posts/:id/comments", + { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (req, reply) => { - const page = Math.max(1, parseInt(req.query.page ?? "1", 10)); + const parsed = parseInt(req.query.page ?? "1", 10); + const page = Math.max(1, Math.min(500, Number.isNaN(parsed) ? 1 : parsed)); const limit = 50; + const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); - if (!post) { + if (!post || post.boardId !== board.id) { reply.status(404).send({ error: "Post not found" }); return; } @@ -31,29 +57,43 @@ export default async function commentRoutes(app: FastifyInstance) { prisma.comment.findMany({ where: { postId: post.id }, orderBy: { createdAt: "asc" }, - skip: (page - 1) * limit, + skip: Math.min((page - 1) * limit, 50000), take: limit, include: { - author: { select: { id: true, displayName: true } }, + author: { select: { id: true, displayName: true, avatarPath: true } }, reactions: { select: { emoji: true, userId: true }, }, + replyTo: { + select: { + id: true, + body: true, + isAdmin: true, + author: { select: { id: true, displayName: true, avatarPath: true } }, + }, + }, }, }), prisma.comment.count({ where: { postId: post.id } }), ]); const grouped = comments.map((c) => { - const reactionMap: Record = {}; + const reactionMap: Record = {}; for (const r of c.reactions) { - if (!reactionMap[r.emoji]) reactionMap[r.emoji] = { count: 0, userIds: [] }; + if (!reactionMap[r.emoji]) reactionMap[r.emoji] = { count: 0 }; reactionMap[r.emoji].count++; - reactionMap[r.emoji].userIds.push(r.userId); } return { id: c.id, body: c.body, - author: c.author, + isAdmin: c.isAdmin, + author: cleanAuthor(c.author), + replyTo: c.replyTo ? { + id: c.replyTo.id, + body: c.replyTo.body.slice(0, 200), + isAdmin: c.replyTo.isAdmin, + authorName: cleanAuthor(c.replyTo.author)?.displayName ?? `Anonymous #${(c.replyTo.author?.id ?? "0000").slice(-4)}`, + } : null, reactions: reactionMap, createdAt: c.createdAt, updatedAt: c.updatedAt, @@ -66,84 +106,316 @@ export default async function commentRoutes(app: FastifyInstance) { app.post<{ Params: { boardSlug: string; id: string }; Body: z.infer }>( "/boards/:boardSlug/posts/:id/comments", - { preHandler: [app.requireUser] }, + { preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 20, timeWindow: "1 hour" } } }, async (req, reply) => { + const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + if (board.isArchived) { + reply.status(403).send({ error: "Board is archived" }); + return; + } + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); - if (!post) { + if (!post || post.boardId !== board.id) { reply.status(404).send({ error: "Post not found" }); return; } + if (post.isThreadLocked) { + reply.status(403).send({ error: "Thread is locked" }); + return; + } const body = createCommentSchema.parse(req.body); - const valid = await verifyChallenge(body.altcha); - if (!valid) { - reply.status(400).send({ error: "Invalid challenge response" }); - return; + + // admins skip ALTCHA, regular users must provide it + if (!req.adminId) { + if (!body.altcha) { + reply.status(400).send({ error: "Challenge response required" }); + return; + } + const valid = await verifyChallenge(body.altcha); + if (!valid) { + reply.status(400).send({ error: "Invalid challenge response" }); + return; + } } + // validate replyToId belongs to the same post + if (body.replyToId) { + const target = await prisma.comment.findUnique({ + where: { id: body.replyToId }, + select: { postId: true }, + }); + if (!target || target.postId !== post.id) { + reply.status(400).send({ error: "Invalid reply target" }); + return; + } + } + + const cleanBody = body.body.replace(INVISIBLE_RE, ''); + const isAdmin = !!req.adminId; const comment = await prisma.comment.create({ data: { - body: body.body, + body: cleanBody, postId: post.id, authorId: req.user!.id, + replyToId: body.replyToId ?? null, + isAdmin, + adminUserId: req.adminId ?? null, }, include: { - author: { select: { id: true, displayName: true } }, + author: { select: { id: true, displayName: true, avatarPath: true } }, + replyTo: { + select: { + id: true, + body: true, + isAdmin: true, + authorId: true, + author: { select: { id: true, displayName: true, avatarPath: true } }, + }, + }, }, }); - await prisma.activityEvent.create({ - data: { - type: "comment_created", - boardId: post.boardId, - postId: post.id, - metadata: {}, - }, - }); + if (body.attachmentIds?.length) { + await prisma.attachment.updateMany({ + where: { + id: { in: body.attachmentIds }, + uploaderId: req.user!.id, + postId: null, + commentId: null, + }, + data: { commentId: comment.id }, + }); + } - reply.status(201).send(comment); + await Promise.all([ + prisma.activityEvent.create({ + data: { + type: isAdmin ? "admin_responded" : "comment_created", + boardId: post.boardId, + postId: post.id, + metadata: {}, + }, + }), + prisma.post.update({ + where: { id: post.id }, + data: { lastActivityAt: new Date() }, + }), + ]); + + // in-app notification: notify post author about new comment (unless they wrote it) + if (post.authorId !== req.user!.id) { + await prisma.notification.create({ + data: { + type: isAdmin ? "admin_response" : "comment", + title: isAdmin ? "Official response" : "New comment", + body: `${isAdmin ? "Admin commented on" : "Someone commented on"} "${post.title}"`, + postId: post.id, + userId: post.authorId, + }, + }); + } + + // in-app notification: notify the replied-to user + if (comment.replyTo && comment.replyTo.authorId !== req.user!.id) { + await prisma.notification.create({ + data: { + type: "reply", + title: isAdmin ? "Admin replied to you" : "New reply", + body: `${isAdmin ? "Admin" : "Someone"} replied to your comment on "${post.title}"`, + postId: post.id, + userId: comment.replyTo.authorId, + }, + }); + } + + // push notify post subscribers for admin comments + if (isAdmin) { + await notifyPostSubscribers(post.id, { + title: "Official response", + body: body.body.slice(0, 100), + url: `/post/${post.id}`, + tag: `response-${post.id}`, + }); + } + + // push notify the quoted user if this is a reply + if (comment.replyTo && comment.replyTo.authorId !== req.user!.id) { + await notifyUserReply(comment.replyTo.authorId, { + title: isAdmin ? "Admin replied to your comment" : "Someone replied to your comment", + body: body.body.slice(0, 100), + url: `/post/${post.id}`, + tag: `reply-${comment.id}`, + }); + } + + // @mention notifications + const mentionRe = /@([a-zA-Z0-9_]{3,30})\b/g; + const mentions = [...new Set(Array.from(cleanBody.matchAll(mentionRe), (m) => m[1]))]; + if (mentions.length > 0) { + const mentioned: string[] = []; + for (const name of mentions.slice(0, 10)) { + const idx = blindIndex(name, blindIndexKey); + const found = await prisma.user.findUnique({ where: { usernameIdx: idx }, select: { id: true } }); + if (found && found.id !== req.user!.id) mentioned.push(found.id); + } + if (mentioned.length > 0) { + await prisma.notification.createMany({ + data: mentioned.map((userId) => ({ + type: "mention", + title: "You were mentioned", + body: `Mentioned in a comment on "${post.title}"`, + postId: post.id, + userId, + })), + }); + for (const uid of mentioned) { + notifyUserReply(uid, { + title: "You were mentioned", + body: cleanBody.slice(0, 100), + url: `/post/${post.id}`, + tag: `mention-${comment.id}`, + }); + } + } + } + + reply.status(201).send({ + id: comment.id, + body: comment.body, + isAdmin: comment.isAdmin, + author: cleanAuthor(comment.author), + replyTo: comment.replyTo ? { + id: comment.replyTo.id, + body: comment.replyTo.body.slice(0, 200), + isAdmin: comment.replyTo.isAdmin, + authorName: cleanAuthor(comment.replyTo.author)?.displayName ?? `Anonymous #${(comment.replyTo.author?.id ?? "0000").slice(-4)}`, + } : null, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + }); } ); app.put<{ Params: { id: string }; Body: z.infer }>( "/comments/:id", - { preHandler: [app.requireUser] }, + { preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => { const comment = await prisma.comment.findUnique({ where: { id: req.params.id } }); if (!comment) { reply.status(404).send({ error: "Comment not found" }); return; } - if (comment.authorId !== req.user!.id) { + // admins can edit their own admin comments, users can edit their own comments + const isOwnComment = comment.authorId === req.user!.id; + const isOwnAdminComment = req.adminId && comment.isAdmin && comment.adminUserId === req.adminId; + if (!isOwnComment && !isOwnAdminComment) { reply.status(403).send({ error: "Not your comment" }); return; } + if (comment.isEditLocked) { + reply.status(403).send({ error: "Editing is locked on this comment" }); + return; + } + const parentPost = await prisma.post.findUnique({ + where: { id: comment.postId }, + select: { isThreadLocked: true }, + }); + if (parentPost?.isThreadLocked) { + reply.status(403).send({ error: "Thread is locked" }); + return; + } + const body = updateCommentSchema.parse(req.body); + + const cleanBody = body.body.replace(INVISIBLE_RE, ''); + if (cleanBody !== comment.body) { + await prisma.editHistory.create({ + data: { + commentId: comment.id, + editedBy: req.user!.id, + previousBody: comment.body, + }, + }); + } + const updated = await prisma.comment.update({ where: { id: comment.id }, - data: { body: body.body }, + data: { body: cleanBody }, }); - reply.send(updated); + reply.send({ + id: updated.id, body: updated.body, isAdmin: updated.isAdmin, + createdAt: updated.createdAt, updatedAt: updated.updatedAt, + }); + } + ); + + app.get<{ Params: { id: string } }>( + "/comments/:id/edits", + { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, + async (req, reply) => { + const comment = await prisma.comment.findUnique({ where: { id: req.params.id } }); + if (!comment) { + reply.status(404).send({ error: "Comment not found" }); + return; + } + + const edits = await prisma.editHistory.findMany({ + where: { commentId: comment.id }, + orderBy: { createdAt: "desc" }, + take: 50, + include: { + editor: { select: { id: true, displayName: true, avatarPath: true } }, + }, + }); + + reply.send(edits.map((e) => ({ + id: e.id, + previousBody: e.previousBody, + editedBy: cleanAuthor(e.editor), + createdAt: e.createdAt, + }))); } ); app.delete<{ Params: { id: string } }>( "/comments/:id", - { preHandler: [app.requireUser] }, + { preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => { const comment = await prisma.comment.findUnique({ where: { id: req.params.id } }); if (!comment) { reply.status(404).send({ error: "Comment not found" }); return; } - if (comment.authorId !== req.user!.id) { + const isOwnComment = comment.authorId === req.user!.id; + const isOwnAdminComment = req.adminId && comment.isAdmin && comment.adminUserId === req.adminId; + if (!isOwnComment && !isOwnAdminComment) { reply.status(403).send({ error: "Not your comment" }); return; } + const attachments = await prisma.attachment.findMany({ + where: { commentId: comment.id }, + select: { id: true, path: true }, + }); + + if (attachments.length) { + await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } }); + } + await prisma.comment.delete({ where: { id: comment.id } }); + + const uploadDir = resolve(process.cwd(), "uploads"); + for (const att of attachments) { + await unlink(resolve(uploadDir, att.path)).catch(() => {}); + } + reply.status(204).send(); } ); diff --git a/packages/api/src/routes/embed.ts b/packages/api/src/routes/embed.ts new file mode 100644 index 0000000..f7a5d75 --- /dev/null +++ b/packages/api/src/routes/embed.ts @@ -0,0 +1,74 @@ +import { FastifyInstance } from "fastify"; +import { prisma } from "../lib/prisma.js"; +import { z } from "zod"; + +const VALID_STATUSES = ["OPEN", "UNDER_REVIEW", "PLANNED", "IN_PROGRESS", "DONE", "DECLINED"] as const; + +const embedQuery = z.object({ + sort: z.enum(["newest", "top"]).default("top"), + status: z.enum(VALID_STATUSES).optional(), + limit: z.coerce.number().int().min(1).max(30).default(10), +}); + +export default async function embedRoutes(app: FastifyInstance) { + app.get<{ Params: { boardSlug: string }; Querystring: Record }>( + "/embed/:boardSlug/posts", + { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, + async (req, reply) => { + const board = await prisma.board.findUnique({ + where: { slug: req.params.boardSlug }, + }); + if (!board || board.isArchived) { + reply.status(404).send({ error: "Board not found" }); + return; + } + + const q = embedQuery.safeParse(req.query); + if (!q.success) { + reply.status(400).send({ error: "Invalid parameters" }); + return; + } + + const { sort, status, limit } = q.data; + const where: Record = { boardId: board.id }; + if (status) where.status = status; + + const orderBy = sort === "top" + ? { voteCount: "desc" as const } + : { createdAt: "desc" as const }; + + const posts = await prisma.post.findMany({ + where, + orderBy: [{ isPinned: "desc" }, orderBy], + take: limit, + select: { + id: true, + title: true, + type: true, + status: true, + voteCount: true, + isPinned: true, + createdAt: true, + _count: { select: { comments: true } }, + }, + }); + + // cache for 60s + reply.header("Cache-Control", "public, max-age=60"); + + reply.send({ + board: { name: board.name, slug: board.slug }, + posts: posts.map((p) => ({ + id: p.id, + title: p.title, + type: p.type, + status: p.status, + voteCount: p.voteCount, + isPinned: p.isPinned, + commentCount: p._count.comments, + createdAt: p.createdAt, + })), + }); + } + ); +} diff --git a/packages/api/src/routes/feed.ts b/packages/api/src/routes/feed.ts index 24b1cf0..1fc16b6 100644 --- a/packages/api/src/routes/feed.ts +++ b/packages/api/src/routes/feed.ts @@ -1,12 +1,105 @@ import { FastifyInstance } from "fastify"; -import { PrismaClient } from "@prisma/client"; import RSS from "rss"; +import prisma from "../lib/prisma.js"; +import { decrypt } from "../services/encryption.js"; +import { masterKey, config } from "../config.js"; -const prisma = new PrismaClient(); +function decryptName(v: string | null): string | null { + if (!v) return null; + try { return decrypt(v, masterKey); } catch { return null; } +} + +function stripHtml(s: string): string { + return s.replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c] || c)); +} + +function excerpt(description: Record, maxLen = 300): string { + const text = Object.values(description) + .filter((v) => typeof v === "string") + .join(" ") + .replace(/\s+/g, " ") + .trim(); + const trimmed = text.length > maxLen ? text.slice(0, maxLen) + "..." : text; + return stripHtml(trimmed); +} + +interface FeedItem { + title: string; + description: string; + author: string; + url: string; + date: Date; + guid: string; + categories?: string[]; +} + +async function buildBoardFeedItems(boardId: string, boardSlug: string, baseUrl: string, itemCount = 50): Promise { + const [posts, statusChanges, adminComments] = await Promise.all([ + prisma.post.findMany({ + where: { boardId }, + orderBy: { createdAt: "desc" }, + take: itemCount, + include: { author: { select: { id: true, displayName: true, avatarPath: true } } }, + }), + prisma.statusChange.findMany({ + where: { post: { boardId } }, + orderBy: { createdAt: "desc" }, + take: Math.ceil(itemCount / 2), + include: { post: { select: { id: true, title: true } } }, + }), + prisma.comment.findMany({ + where: { post: { boardId }, isAdmin: true }, + orderBy: { createdAt: "desc" }, + take: Math.ceil(itemCount / 2), + include: { post: { select: { id: true, title: true } } }, + }), + ]); + + const items: FeedItem[] = []; + + for (const post of posts) { + const typeLabel = post.type === "BUG_REPORT" ? "Bug Report" : "Feature Request"; + items.push({ + title: `[${typeLabel}] ${post.title}`, + description: `${excerpt(post.description as Record)} (${post.voteCount} votes)`, + author: stripHtml(decryptName(post.author?.displayName ?? null) ?? `Anonymous #${(post.author?.id ?? "0000").slice(-4)}`), + url: `${baseUrl}/b/${boardSlug}/post/${post.id}`, + date: post.createdAt, + guid: post.id, + categories: [typeLabel, ...(post.category ? [post.category] : [])], + }); + } + + for (const sc of statusChanges) { + items.push({ + title: `Status: ${sc.post.title} - ${sc.fromStatus} to ${sc.toStatus}`, + description: `"${sc.post.title}" moved from ${sc.fromStatus} to ${sc.toStatus}`, + author: "Admin", + url: `${baseUrl}/b/${boardSlug}/post/${sc.postId}`, + date: sc.createdAt, + guid: `${sc.postId}-status-${sc.id}`, + }); + } + + for (const ac of adminComments) { + items.push({ + title: `Official response: ${ac.post.title}`, + description: stripHtml(ac.body.length > 300 ? ac.body.slice(0, 300) + "..." : ac.body), + author: "Admin", + url: `${baseUrl}/b/${boardSlug}/post/${ac.postId}`, + date: ac.createdAt, + guid: `${ac.postId}-response-${ac.id}`, + }); + } + + items.sort((a, b) => b.date.getTime() - a.date.getTime()); + return items.slice(0, itemCount); +} export default async function feedRoutes(app: FastifyInstance) { app.get<{ Params: { boardSlug: string } }>( "/boards/:boardSlug/feed.rss", + { config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, async (req, reply) => { const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); if (!board) { @@ -14,54 +107,59 @@ export default async function feedRoutes(app: FastifyInstance) { return; } - const posts = await prisma.post.findMany({ - where: { boardId: board.id }, - orderBy: { createdAt: "desc" }, - take: 50, - }); + if (!board.rssEnabled) { + reply.status(404).send({ error: "RSS feed disabled for this board" }); + return; + } + + const baseUrl = config.WEBAUTHN_ORIGIN; + const items = await buildBoardFeedItems(board.id, board.slug, baseUrl, board.rssFeedCount); const feed = new RSS({ title: `${board.name} - Echoboard`, description: board.description ?? "", - feed_url: `${req.protocol}://${req.hostname}/api/v1/boards/${board.slug}/feed.rss`, - site_url: `${req.protocol}://${req.hostname}`, + feed_url: `${baseUrl}/api/v1/boards/${board.slug}/feed.rss`, + site_url: baseUrl, }); - for (const post of posts) { - feed.item({ - title: post.title, - description: `[${post.type}] ${post.status} - ${post.voteCount} votes`, - url: `${req.protocol}://${req.hostname}/board/${board.slug}/post/${post.id}`, - date: post.createdAt, - categories: post.category ? [post.category] : [], - }); + for (const item of items) { + feed.item(item); } reply.header("Content-Type", "application/rss+xml").send(feed.xml({ indent: true })); } ); - app.get("/feed.rss", async (req, reply) => { - const posts = await prisma.post.findMany({ - orderBy: { createdAt: "desc" }, - take: 50, - include: { board: { select: { slug: true, name: true } } }, + app.get("/feed.rss", { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => { + const boards = await prisma.board.findMany({ + where: { isArchived: false, rssEnabled: true }, + select: { id: true, slug: true, name: true, rssFeedCount: true }, + take: 20, }); + const baseUrl = config.WEBAUTHN_ORIGIN; + const allItems: FeedItem[] = []; + + const boardResults = await Promise.all( + boards.map((board) => + buildBoardFeedItems(board.id, board.slug, baseUrl, Math.min(board.rssFeedCount, 50)) + .then((items) => items.map((item) => ({ ...item, title: `[${board.name}] ${item.title}` }))) + ) + ); + for (const items of boardResults) { + allItems.push(...items); + } + + allItems.sort((a, b) => b.date.getTime() - a.date.getTime()); + const feed = new RSS({ title: "Echoboard - All Feedback", - feed_url: `${req.protocol}://${req.hostname}/api/v1/feed.rss`, - site_url: `${req.protocol}://${req.hostname}`, + feed_url: `${baseUrl}/api/v1/feed.rss`, + site_url: baseUrl, }); - for (const post of posts) { - feed.item({ - title: `[${post.board.name}] ${post.title}`, - description: `[${post.type}] ${post.status} - ${post.voteCount} votes`, - url: `${req.protocol}://${req.hostname}/board/${post.board.slug}/post/${post.id}`, - date: post.createdAt, - categories: post.category ? [post.category] : [], - }); + for (const item of allItems.slice(0, 50)) { + feed.item(item); } reply.header("Content-Type", "application/rss+xml").send(feed.xml({ indent: true })); diff --git a/packages/api/src/routes/identity.ts b/packages/api/src/routes/identity.ts index 763c184..dea0bc0 100644 --- a/packages/api/src/routes/identity.ts +++ b/packages/api/src/routes/identity.ts @@ -1,19 +1,22 @@ import { FastifyInstance } from "fastify"; -import { PrismaClient } from "@prisma/client"; import { randomBytes } from "node:crypto"; +import { resolve } from "node:path"; +import { unlink } from "node:fs/promises"; import { z } from "zod"; -import { hashToken, encrypt, decrypt } from "../services/encryption.js"; -import { masterKey } from "../config.js"; - -const prisma = new PrismaClient(); +import prisma from "../lib/prisma.js"; +import { hashToken, encrypt, decrypt, blindIndex } from "../services/encryption.js"; +import { masterKey, blindIndexKey } from "../config.js"; +import { verifyChallenge } from "../services/altcha.js"; +import { blockToken } from "../lib/token-blocklist.js"; const updateMeSchema = z.object({ displayName: z.string().max(50).optional().nullable(), darkMode: z.enum(["system", "light", "dark"]).optional(), + altcha: z.string().optional(), }); export default async function identityRoutes(app: FastifyInstance) { - app.post("/identity", async (_req, reply) => { + app.post("/identity", { config: { rateLimit: { max: 5, timeWindow: "1 hour" } } }, async (_req, reply) => { const token = randomBytes(32).toString("hex"); const hash = hashToken(token); @@ -27,7 +30,7 @@ export default async function identityRoutes(app: FastifyInstance) { httpOnly: true, sameSite: "strict", secure: process.env.NODE_ENV === "production", - maxAge: 60 * 60 * 24 * 365, + maxAge: 60 * 60 * 24 * 90, }) .status(201) .send({ @@ -37,15 +40,57 @@ export default async function identityRoutes(app: FastifyInstance) { }); }); - app.put<{ Body: z.infer }>( + app.get( "/me", { preHandler: [app.requireUser] }, + async (req, reply) => { + const user = await prisma.user.findUnique({ + where: { id: req.user!.id }, + include: { recoveryCode: { select: { expiresAt: true } } }, + }); + if (!user) { + reply.status(404).send({ error: "User not found" }); + return; + } + + reply.send({ + id: user.id, + displayName: user.displayName ? decrypt(user.displayName, masterKey) : null, + username: user.username ? decrypt(user.username, masterKey) : null, + isPasskeyUser: user.authMethod === "PASSKEY", + avatarUrl: user.avatarPath ? `/api/v1/avatars/${user.id}` : null, + darkMode: user.darkMode, + hasRecoveryCode: !!user.recoveryCode && user.recoveryCode.expiresAt > new Date(), + recoveryCodeExpiresAt: user.recoveryCode?.expiresAt ?? null, + createdAt: user.createdAt, + }); + } + ); + + app.put<{ Body: z.infer }>( + "/me", + { preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => { const body = updateMeSchema.parse(req.body); const data: Record = {}; + if (body.displayName !== undefined && body.displayName !== null) { + if (!body.altcha) { + reply.status(400).send({ error: "Verification required for display name changes" }); + return; + } + const valid = await verifyChallenge(body.altcha); + if (!valid) { + reply.status(400).send({ error: "Invalid challenge response" }); + return; + } + } + if (body.displayName !== undefined) { - data.displayName = body.displayName ? encrypt(body.displayName, masterKey) : null; + const cleanName = body.displayName + ? body.displayName.replace(/[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g, '') + : null; + data.displayName = cleanName ? encrypt(cleanName, masterKey) : null; } if (body.darkMode !== undefined) { data.darkMode = body.darkMode; @@ -59,62 +104,276 @@ export default async function identityRoutes(app: FastifyInstance) { reply.send({ id: updated.id, displayName: updated.displayName ? decrypt(updated.displayName, masterKey) : null, + username: updated.username ? decrypt(updated.username, masterKey) : null, + isPasskeyUser: updated.authMethod === "PASSKEY", + avatarUrl: updated.avatarPath ? `/api/v1/avatars/${updated.id}` : null, darkMode: updated.darkMode, - authMethod: updated.authMethod, + createdAt: updated.createdAt, }); } ); app.get( "/me/posts", - { preHandler: [app.requireUser] }, + { preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => { - const posts = await prisma.post.findMany({ - where: { authorId: req.user!.id }, - orderBy: { createdAt: "desc" }, - include: { - board: { select: { slug: true, name: true } }, - _count: { select: { comments: true } }, - }, - }); + const page = Math.max(1, Math.min(500, parseInt((req.query as any).page ?? '1', 10) || 1)); - reply.send(posts.map((p) => ({ - id: p.id, - type: p.type, - title: p.title, - status: p.status, - voteCount: p.voteCount, - commentCount: p._count.comments, - board: p.board, - createdAt: p.createdAt, - }))); + const [posts, total] = await Promise.all([ + prisma.post.findMany({ + where: { authorId: req.user!.id }, + orderBy: { createdAt: "desc" }, + take: 50, + skip: (page - 1) * 50, + include: { + board: { select: { slug: true, name: true } }, + _count: { select: { comments: true } }, + }, + }), + prisma.post.count({ where: { authorId: req.user!.id } }), + ]); + + reply.send({ + posts: posts.map((p) => ({ + id: p.id, + type: p.type, + title: p.title, + status: p.status, + voteCount: p.voteCount, + commentCount: p._count.comments, + board: p.board, + createdAt: p.createdAt, + })), + total, + page, + pages: Math.ceil(total / 50), + }); } ); - app.delete( - "/me", - { preHandler: [app.requireUser] }, + app.get( + "/me/profile", + { preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => { - await prisma.user.delete({ where: { id: req.user!.id } }); + const userId = req.user!.id; + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { reply.status(404).send({ error: "User not found" }); return; } + + const [postCount, commentCount, voteCount, votesReceived] = await Promise.all([ + prisma.post.count({ where: { authorId: userId } }), + prisma.comment.count({ where: { authorId: userId } }), + prisma.vote.count({ where: { voterId: userId } }), + prisma.vote.aggregate({ + _sum: { weight: true }, + where: { post: { authorId: userId } }, + }), + ]); + + const votedPosts = await prisma.vote.findMany({ + where: { voterId: userId }, + orderBy: { createdAt: "desc" }, + take: 20, + include: { + post: { + select: { + id: true, + title: true, + status: true, + voteCount: true, + board: { select: { slug: true, name: true } }, + _count: { select: { comments: true } }, + }, + }, + }, + }); + + reply.send({ + id: user.id, + displayName: user.displayName ? decrypt(user.displayName, masterKey) : null, + username: user.username ? decrypt(user.username, masterKey) : null, + isPasskeyUser: user.authMethod === "PASSKEY", + avatarUrl: user.avatarPath ? `/api/v1/avatars/${user.id}` : null, + createdAt: user.createdAt, + stats: { + posts: postCount, + comments: commentCount, + votesGiven: voteCount, + votesReceived: votesReceived._sum.weight ?? 0, + }, + votedPosts: votedPosts + .filter((v) => v.post) + .map((v) => ({ + id: v.post.id, + title: v.post.title, + status: v.post.status, + voteCount: v.post.voteCount, + commentCount: v.post._count.comments, + board: v.post.board, + votedAt: v.createdAt, + })), + }); + } + ); + + app.delete<{ Body: { altcha: string } }>( + "/me", + { preHandler: [app.requireUser], config: { rateLimit: { max: 3, timeWindow: "1 hour" } } }, + async (req, reply) => { + const { altcha } = (req.body as any) || {}; + if (!altcha) { + reply.status(400).send({ error: "Verification required to delete account" }); + return; + } + const challengeValid = await verifyChallenge(altcha); + if (!challengeValid) { + reply.status(400).send({ error: "Invalid challenge response" }); + return; + } + + const userId = req.user!.id; + + // collect user's avatar path before deletion + const userForAvatar = await prisma.user.findUnique({ + where: { id: userId }, + select: { avatarPath: true }, + }); + + // collect attachment paths before deletion + const attachments = await prisma.attachment.findMany({ + where: { uploaderId: userId }, + select: { path: true }, + }); + + // ensure a sentinel "deleted user" exists for orphaned content + const sentinelId = "deleted-user-sentinel"; + await prisma.user.upsert({ + where: { id: sentinelId }, + create: { id: sentinelId, authMethod: "COOKIE", darkMode: "system" }, + update: {}, + }); + + await prisma.$transaction(async (tx) => { + // 1. remove votes and recalculate voteCount on affected posts + const votes = await tx.vote.findMany({ + where: { voterId: userId }, + select: { postId: true, weight: true }, + }); + await tx.vote.deleteMany({ where: { voterId: userId } }); + for (const vote of votes) { + await tx.post.update({ + where: { id: vote.postId }, + data: { voteCount: { decrement: vote.weight } }, + }); + } + + // 2. remove reactions + await tx.reaction.deleteMany({ where: { userId } }); + + // 3. delete push subscriptions + await tx.pushSubscription.deleteMany({ where: { userId } }); + + // 4. anonymize comments - body becomes "[deleted]", author set to sentinel + await tx.comment.updateMany({ + where: { authorId: userId }, + data: { body: "[deleted]", authorId: sentinelId }, + }); + + // 5. anonymize posts - title/description replaced, author set to sentinel + const userPosts = await tx.post.findMany({ + where: { authorId: userId }, + select: { id: true }, + }); + for (const post of userPosts) { + await tx.post.update({ + where: { id: post.id }, + data: { + title: "[deleted by author]", + description: {}, + authorId: sentinelId, + }, + }); + } + + // 6. wipe passkeys and recovery codes + await tx.passkey.deleteMany({ where: { userId } }); + await tx.recoveryCode.deleteMany({ where: { userId } }); + + // 7. remove attachment records + await tx.attachment.deleteMany({ where: { uploaderId: userId } }); + + // 8. purge user record + await tx.user.delete({ where: { id: userId } }); + }); + + // clean up files on disk after successful transaction + const uploadDir = resolve(process.cwd(), "uploads"); + for (const att of attachments) { + await unlink(resolve(uploadDir, att.path)).catch(() => {}); + } + if (userForAvatar?.avatarPath) { + await unlink(resolve(uploadDir, "avatars", userForAvatar.avatarPath)).catch(() => {}); + } + + const passkeyToken = req.cookies?.echoboard_passkey; + if (passkeyToken) await blockToken(passkeyToken); reply .clearCookie("echoboard_token", { path: "/" }) + .clearCookie("echoboard_passkey", { path: "/" }) + .clearCookie("echoboard_admin", { path: "/" }) .send({ ok: true }); } ); + app.get<{ Querystring: { q?: string } }>( + "/users/search", + { preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (req, reply) => { + const q = (req.query.q ?? "").trim().toLowerCase(); + if (q.length < 2 || q.length > 30) { + reply.send({ users: [] }); + return; + } + const users = await prisma.user.findMany({ + where: { usernameIdx: { not: null } }, + select: { id: true, username: true, avatarPath: true }, + take: 200, + }); + const matches = users + .map((u) => { + let name: string | null = null; + if (u.username) { + try { name = decrypt(u.username, masterKey); } catch {} + } + return { + id: u.id, + username: name, + avatarUrl: u.avatarPath ? `/api/v1/avatars/${u.id}` : null, + }; + }) + .filter((u) => u.username && u.username.toLowerCase().startsWith(q)) + .slice(0, 10); + reply.send({ users: matches }); + } + ); + app.get( "/me/export", - { preHandler: [app.requireUser] }, + { preHandler: [app.requireUser], config: { rateLimit: { max: 5, timeWindow: "1 hour" } } }, async (req, reply) => { const userId = req.user!.id; - const [user, posts, comments, votes, reactions] = await Promise.all([ + const [user, posts, comments, votes, reactions, pushSubs, notifications] = await Promise.all([ prisma.user.findUnique({ where: { id: userId } }), prisma.post.findMany({ where: { authorId: userId } }), prisma.comment.findMany({ where: { authorId: userId } }), prisma.vote.findMany({ where: { voterId: userId } }), prisma.reaction.findMany({ where: { userId } }), + prisma.pushSubscription.findMany({ + where: { userId }, + select: { boardId: true, postId: true, createdAt: true }, + }), + prisma.notification.findMany({ where: { userId } }), ]); const decryptedUser = user ? { @@ -128,10 +387,31 @@ export default async function identityRoutes(app: FastifyInstance) { reply.send({ user: decryptedUser, - posts, - comments, + posts: posts.map((p) => ({ + id: p.id, type: p.type, title: p.title, status: p.status, + voteCount: p.voteCount, category: p.category, + boardId: p.boardId, createdAt: p.createdAt, + })), + comments: comments.map((c) => ({ + id: c.id, body: c.body, postId: c.postId, isAdmin: c.isAdmin, createdAt: c.createdAt, + })), votes: votes.map((v) => ({ postId: v.postId, weight: v.weight, createdAt: v.createdAt })), reactions: reactions.map((r) => ({ commentId: r.commentId, emoji: r.emoji, createdAt: r.createdAt })), + notifications: notifications.map((n) => ({ + id: n.id, type: n.type, title: n.title, body: n.body, + postId: n.postId, read: n.read, createdAt: n.createdAt, + })), + pushSubscriptions: pushSubs, + dataManifest: { + trackedModels: ["User", "Passkey", "PushSubscription", "Notification", "RecoveryCode"], + fields: { + User: ["id", "authMethod", "displayName", "username", "avatarPath", "darkMode", "createdAt"], + Passkey: ["id", "credentialId", "credentialDeviceType", "transports", "createdAt"], + PushSubscription: ["id", "boardId", "postId", "createdAt"], + Notification: ["id", "type", "title", "body", "postId", "read", "createdAt"], + RecoveryCode: ["id", "expiresAt", "createdAt"], + }, + }, exportedAt: new Date().toISOString(), }); } diff --git a/packages/api/src/routes/notifications.ts b/packages/api/src/routes/notifications.ts new file mode 100644 index 0000000..6d3fc7c --- /dev/null +++ b/packages/api/src/routes/notifications.ts @@ -0,0 +1,43 @@ +import { FastifyInstance } from "fastify"; +import { prisma } from "../lib/prisma.js"; + +export default async function notificationRoutes(app: FastifyInstance) { + app.get( + "/notifications", + { preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, + async (req, reply) => { + const notifications = await prisma.notification.findMany({ + where: { userId: req.user!.id }, + orderBy: { createdAt: "desc" }, + take: 30, + include: { + post: { + select: { + id: true, + title: true, + board: { select: { slug: true } }, + }, + }, + }, + }); + + const unread = await prisma.notification.count({ + where: { userId: req.user!.id, read: false }, + }); + + reply.send({ notifications, unread }); + } + ); + + app.put( + "/notifications/read", + { preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + await prisma.notification.updateMany({ + where: { userId: req.user!.id, read: false }, + data: { read: true }, + }); + reply.send({ ok: true }); + } + ); +} diff --git a/packages/api/src/routes/passkey.ts b/packages/api/src/routes/passkey.ts index c9b5c90..e9a9c30 100644 --- a/packages/api/src/routes/passkey.ts +++ b/packages/api/src/routes/passkey.ts @@ -1,5 +1,4 @@ import { FastifyInstance } from "fastify"; -import { PrismaClient } from "@prisma/client"; import { generateRegistrationOptions, verifyRegistrationResponse, @@ -9,28 +8,45 @@ import { import type { RegistrationResponseJSON, AuthenticationResponseJSON, -} from "@simplewebauthn/server"; +} from "@simplewebauthn/types"; import jwt from "jsonwebtoken"; import { z } from "zod"; +import prisma from "../lib/prisma.js"; import { config, masterKey, blindIndexKey } from "../config.js"; import { encrypt, decrypt, blindIndex } from "../services/encryption.js"; +import { blockToken } from "../lib/token-blocklist.js"; -const prisma = new PrismaClient(); +// challenge store keyed by purpose:userId for proper isolation +const challenges = new Map(); -const challenges = new Map(); +const MAX_CHALLENGES = 10000; -function storeChallenge(userId: string, challenge: string) { - challenges.set(userId, { challenge, expires: Date.now() + 5 * 60 * 1000 }); +function storeChallenge(key: string, challenge: string, username?: string) { + if (challenges.size >= MAX_CHALLENGES) { + const now = Date.now(); + for (const [k, v] of challenges) { + if (v.expires < now) challenges.delete(k); + } + if (challenges.size >= MAX_CHALLENGES) { + const iter = challenges.keys(); + for (let i = 0; i < 100; i++) { + const k = iter.next(); + if (k.done) break; + challenges.delete(k.value); + } + } + } + challenges.set(key, { challenge, expires: Date.now() + 60 * 1000, username }); } -function getChallenge(userId: string): string | null { - const entry = challenges.get(userId); +function getChallenge(key: string): { challenge: string; username?: string } | null { + const entry = challenges.get(key); if (!entry || entry.expires < Date.now()) { - challenges.delete(userId); + challenges.delete(key); return null; } - challenges.delete(userId); - return entry.challenge; + challenges.delete(key); + return { challenge: entry.challenge, username: entry.username }; } export function cleanExpiredChallenges() { @@ -45,9 +61,11 @@ const registerBody = z.object({ }); export default async function passkeyRoutes(app: FastifyInstance) { + const passkeyRateLimit = { rateLimit: { max: 5, timeWindow: "1 minute" } }; + app.post<{ Body: z.infer }>( "/auth/passkey/register/options", - { preHandler: [app.requireUser] }, + { preHandler: [app.requireUser], config: passkeyRateLimit }, async (req, reply) => { const { username } = registerBody.parse(req.body); const user = req.user!; @@ -76,23 +94,30 @@ export default async function passkeyRoutes(app: FastifyInstance) { }, }); - storeChallenge(user.id, options.challenge); + storeChallenge("register:" + user.id, options.challenge, username); reply.send(options); } ); app.post<{ Body: { response: RegistrationResponseJSON; username: string } }>( "/auth/passkey/register/verify", - { preHandler: [app.requireUser] }, + { preHandler: [app.requireUser], config: passkeyRateLimit }, async (req, reply) => { const user = req.user!; const { response, username } = req.body; - const expectedChallenge = getChallenge(user.id); - if (!expectedChallenge) { + const stored = getChallenge("register:" + user.id); + if (!stored) { reply.status(400).send({ error: "Challenge expired" }); return; } + const expectedChallenge = stored.challenge; + + const validName = registerBody.safeParse({ username }); + if (!validName.success || username !== stored.username) { + reply.status(400).send({ error: "Invalid username" }); + return; + } let verification; try { @@ -102,8 +127,8 @@ export default async function passkeyRoutes(app: FastifyInstance) { expectedOrigin: config.WEBAUTHN_ORIGIN, expectedRPID: config.WEBAUTHN_RP_ID, }); - } catch (err: any) { - reply.status(400).send({ error: err.message }); + } catch { + reply.status(400).send({ error: "Registration verification failed" }); return; } @@ -116,11 +141,13 @@ export default async function passkeyRoutes(app: FastifyInstance) { const credIdStr = Buffer.from(credential.id).toString("base64url"); + const pubKeyEncrypted = encrypt(Buffer.from(credential.publicKey).toString("base64"), masterKey); + await prisma.passkey.create({ data: { credentialId: encrypt(credIdStr, masterKey), credentialIdIdx: blindIndex(credIdStr, blindIndexKey), - credentialPublicKey: Buffer.from(credential.publicKey), + credentialPublicKey: Buffer.from(pubKeyEncrypted), counter: BigInt(credential.counter), credentialDeviceType, credentialBackedUp, @@ -145,6 +172,7 @@ export default async function passkeyRoutes(app: FastifyInstance) { app.post( "/auth/passkey/login/options", + { config: passkeyRateLimit }, async (_req, reply) => { const options = await generateAuthenticationOptions({ rpID: config.WEBAUTHN_RP_ID, @@ -158,6 +186,7 @@ export default async function passkeyRoutes(app: FastifyInstance) { app.post<{ Body: { response: AuthenticationResponseJSON } }>( "/auth/passkey/login/verify", + { config: passkeyRateLimit }, async (req, reply) => { const { response } = req.body; @@ -166,24 +195,25 @@ export default async function passkeyRoutes(app: FastifyInstance) { const passkey = await prisma.passkey.findUnique({ where: { credentialIdIdx: credIdx } }); if (!passkey) { - reply.status(400).send({ error: "Passkey not found" }); + reply.status(400).send({ error: "Authentication failed" }); return; } - const expectedChallenge = getChallenge("login:" + response.response.clientDataJSON); - // we stored with the challenge value, try to find it - let challenge: string | null = null; - for (const [key, val] of challenges) { - if (key.startsWith("login:") && val.expires > Date.now()) { - challenge = val.challenge; - challenges.delete(key); - break; - } + let clientData: { challenge?: string }; + try { + clientData = JSON.parse( + Buffer.from(response.response.clientDataJSON, "base64url").toString() + ); + } catch { + reply.status(400).send({ error: "Invalid client data" }); + return; } - if (!challenge) { + const stored = getChallenge("login:" + clientData.challenge); + if (!stored) { reply.status(400).send({ error: "Challenge expired" }); return; } + const challenge = stored.challenge; let verification; try { @@ -194,15 +224,15 @@ export default async function passkeyRoutes(app: FastifyInstance) { expectedRPID: config.WEBAUTHN_RP_ID, credential: { id: decrypt(passkey.credentialId, masterKey), - publicKey: new Uint8Array(passkey.credentialPublicKey), + publicKey: new Uint8Array(Buffer.from(decrypt(passkey.credentialPublicKey.toString(), masterKey), "base64")), counter: Number(passkey.counter), transports: passkey.transports ? JSON.parse(decrypt(passkey.transports, masterKey)) : undefined, }, }); - } catch (err: any) { - reply.status(400).send({ error: err.message }); + } catch { + reply.status(400).send({ error: "Authentication failed" }); return; } @@ -211,36 +241,113 @@ export default async function passkeyRoutes(app: FastifyInstance) { return; } - await prisma.passkey.update({ - where: { id: passkey.id }, - data: { counter: BigInt(verification.authenticationInfo.newCounter) }, - }); + try { + await prisma.$transaction(async (tx) => { + const current = await tx.passkey.findUnique({ + where: { id: passkey.id }, + select: { counter: true }, + }); + if (current && current.counter >= BigInt(verification.authenticationInfo.newCounter)) { + throw new Error("counter_rollback"); + } + await tx.passkey.update({ + where: { id: passkey.id }, + data: { counter: BigInt(verification.authenticationInfo.newCounter) }, + }); + }); + } catch (err: any) { + if (err.message === "counter_rollback") { + req.log.warn({ passkeyId: passkey.id, userId: passkey.userId }, "passkey counter rollback detected - possible credential cloning, disabling passkey"); + await prisma.passkey.delete({ where: { id: passkey.id } }).catch(() => {}); + } + reply.status(400).send({ error: "Authentication failed" }); + return; + } const token = jwt.sign( - { sub: passkey.userId, type: "passkey" }, + { sub: passkey.userId, type: "passkey", aud: "echoboard:user", iss: "echoboard" }, config.JWT_SECRET, - { expiresIn: "30d" } + { expiresIn: "24h" } ); - reply.send({ verified: true, token }); + reply + .setCookie("echoboard_passkey", token, { + path: "/", + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 24, + }) + .send({ verified: true }); + } + ); + + app.get( + "/me/passkeys", + { preHandler: [app.requireUser], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, + async (req, reply) => { + const passkeys = await prisma.passkey.findMany({ + where: { userId: req.user!.id }, + select: { + id: true, + credentialDeviceType: true, + credentialBackedUp: true, + createdAt: true, + }, + orderBy: { createdAt: "asc" }, + }); + reply.send(passkeys); + } + ); + + app.delete<{ Params: { id: string } }>( + "/me/passkeys/:id", + { preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const passkey = await prisma.passkey.findUnique({ + where: { id: req.params.id }, + }); + if (!passkey || passkey.userId !== req.user!.id) { + reply.status(404).send({ error: "Passkey not found" }); + return; + } + + const count = await prisma.passkey.count({ where: { userId: req.user!.id } }); + if (count <= 1) { + reply.status(400).send({ error: "Cannot remove your only passkey" }); + return; + } + + await prisma.passkey.delete({ where: { id: passkey.id } }); + reply.status(204).send(); } ); app.post( "/auth/passkey/logout", - { preHandler: [app.requireUser] }, - async (_req, reply) => { + { preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const passkeyToken = req.cookies?.echoboard_passkey; + if (passkeyToken) await blockToken(passkeyToken); + reply .clearCookie("echoboard_token", { path: "/" }) + .clearCookie("echoboard_passkey", { path: "/" }) + .clearCookie("echoboard_admin", { path: "/" }) .send({ ok: true }); } ); app.get<{ Params: { name: string } }>( "/auth/passkey/check-username/:name", + { config: { rateLimit: { max: 5, timeWindow: "5 minutes" } } }, async (req, reply) => { + const start = Date.now(); const hash = blindIndex(req.params.name, blindIndexKey); const existing = await prisma.user.findUnique({ where: { usernameIdx: hash } }); + // constant-time response to prevent timing-based enumeration + const elapsed = Date.now() - start; + if (elapsed < 100) await new Promise((r) => setTimeout(r, 100 - elapsed)); reply.send({ available: !existing }); } ); diff --git a/packages/api/src/routes/posts.ts b/packages/api/src/routes/posts.ts index 7cc3fdf..3b26d59 100644 --- a/packages/api/src/routes/posts.ts +++ b/packages/api/src/routes/posts.ts @@ -1,38 +1,163 @@ import { FastifyInstance } from "fastify"; -import { PrismaClient, PostType, PostStatus, Prisma } from "@prisma/client"; +import { PostType, Prisma } from "@prisma/client"; import { z } from "zod"; +import { unlink } from "node:fs/promises"; +import { resolve } from "node:path"; +import prisma from "../lib/prisma.js"; import { verifyChallenge } from "../services/altcha.js"; +import { fireWebhook } from "../services/webhooks.js"; +import { decrypt } from "../services/encryption.js"; +import { masterKey } from "../config.js"; +import { shouldCount } from "../lib/view-tracker.js"; +import { notifyBoardSubscribers } from "../services/push.js"; -const prisma = new PrismaClient(); +const INVISIBLE_RE = /[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g; + +function decryptName(encrypted: string | null): string | null { + if (!encrypted) return null; + try { return decrypt(encrypted, masterKey); } catch { return null; } +} + +function cleanAuthor(author: { id: string; displayName: string | null; avatarPath?: string | null } | null) { + if (!author) return null; + return { + id: author.id, + displayName: decryptName(author.displayName), + avatarUrl: author.avatarPath ? `/api/v1/avatars/${author.id}` : null, + }; +} + +const bugReportSchema = z.object({ + stepsToReproduce: z.string().min(1), + expectedBehavior: z.string().min(1), + actualBehavior: z.string().min(1), + environment: z.string().optional(), + additionalContext: z.string().optional(), +}); + +const featureRequestSchema = z.object({ + useCase: z.string().min(1), + proposedSolution: z.string().optional(), + alternativesConsidered: z.string().optional(), + additionalContext: z.string().optional(), +}); + +const allowedDescKeys = new Set(["stepsToReproduce", "expectedBehavior", "actualBehavior", "environment", "useCase", "proposedSolution", "alternativesConsidered", "additionalContext"]); + +const descriptionRecord = z.record(z.string().max(5000)).refine( + (obj) => Object.keys(obj).length <= 10 && Object.keys(obj).every((k) => allowedDescKeys.has(k)), + { message: "Unknown description fields" } +); + +const templateDescRecord = z.record(z.string().max(5000)).refine( + (obj) => Object.keys(obj).length <= 30, + { message: "Too many description fields" } +); const createPostSchema = z.object({ type: z.nativeEnum(PostType), - title: z.string().min(3).max(200), - description: z.any(), + title: z.string().min(5).max(200), + description: z.record(z.string().max(5000)), category: z.string().optional(), + templateId: z.string().optional(), + attachmentIds: z.array(z.string()).max(10).optional(), altcha: z.string(), +}).superRefine((data, ctx) => { + if (data.templateId) { + // template posts use flexible description keys + const tResult = templateDescRecord.safeParse(data.description); + if (!tResult.success) { + for (const issue of tResult.error.issues) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: issue.message, + path: ["description", ...issue.path], + }); + } + } + } else { + // standard posts use strict schema + const dr = descriptionRecord.safeParse(data.description); + if (!dr.success) { + for (const issue of dr.error.issues) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: issue.message, + path: ["description", ...issue.path], + }); + } + } + const result = data.type === PostType.BUG_REPORT + ? bugReportSchema.safeParse(data.description) + : featureRequestSchema.safeParse(data.description); + if (!result.success) { + for (const issue of result.error.issues) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: issue.message, + path: ["description", ...issue.path], + }); + } + } + } + const raw = JSON.stringify(data.description); + if (raw.length < 20) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Description too short (at least 20 characters required)", + path: ["description"], + }); + } + if (raw.length > 5000) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Description too long (max 5000 characters total)", + path: ["description"], + }); + } }); const updatePostSchema = z.object({ - title: z.string().min(3).max(200).optional(), - description: z.any().optional(), + title: z.string().min(5).max(200).optional(), + description: z.record(z.string().max(5000)).optional(), category: z.string().optional().nullable(), + altcha: z.string().optional(), +}).superRefine((data, ctx) => { + if (data.description) { + const raw = JSON.stringify(data.description); + if (raw.length < 20) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Description too short (at least 20 characters required)", + path: ["description"], + }); + } + if (raw.length > 5000) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Description too long (max 5000 characters total)", + path: ["description"], + }); + } + } }); +const VALID_STATUSES = ["OPEN", "UNDER_REVIEW", "PLANNED", "IN_PROGRESS", "DONE", "DECLINED"] as const; + const querySchema = z.object({ type: z.nativeEnum(PostType).optional(), - category: z.string().optional(), - status: z.nativeEnum(PostStatus).optional(), - sort: z.enum(["newest", "oldest", "top", "trending"]).default("newest"), - search: z.string().optional(), - page: z.coerce.number().min(1).default(1), - limit: z.coerce.number().min(1).max(100).default(20), + category: z.string().max(50).optional(), + status: z.enum(VALID_STATUSES).optional(), + sort: z.enum(["newest", "oldest", "top", "updated"]).default("newest"), + search: z.string().max(200).optional(), + page: z.coerce.number().int().min(1).max(500).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), }); export default async function postRoutes(app: FastifyInstance) { app.get<{ Params: { boardSlug: string }; Querystring: Record }>( "/boards/:boardSlug/posts", - { preHandler: [app.optionalUser] }, + { preHandler: [app.optionalUser], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (req, reply) => { const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); if (!board) { @@ -46,13 +171,29 @@ export default async function postRoutes(app: FastifyInstance) { if (q.type) where.type = q.type; if (q.category) where.category = q.category; if (q.status) where.status = q.status; - if (q.search) where.title = { contains: q.search, mode: "insensitive" }; + if (q.search) { + const likeTerm = `%${q.search}%`; + const matchIds = await prisma.$queryRaw<{ id: string }[]>` + SELECT id FROM "Post" + WHERE "boardId" = ${board.id} + AND ( + word_similarity(${q.search}, title) > 0.15 + OR to_tsvector('english', title) @@ plainto_tsquery('english', ${q.search}) + OR description::text ILIKE ${likeTerm} + ) + `; + if (matchIds.length === 0) { + reply.send({ posts: [], total: 0, page: q.page, pages: 0, staleDays: board.staleDays }); + return; + } + where.id = { in: matchIds.map((m) => m.id) }; + } let orderBy: Prisma.PostOrderByWithRelationInput; switch (q.sort) { case "oldest": orderBy = { createdAt: "asc" }; break; case "top": orderBy = { voteCount: "desc" }; break; - case "trending": orderBy = { voteCount: "desc" }; break; + case "updated": orderBy = { updatedAt: "desc" }; break; default: orderBy = { createdAt: "desc" }; } @@ -64,70 +205,266 @@ export default async function postRoutes(app: FastifyInstance) { take: q.limit, include: { _count: { select: { comments: true } }, - author: { select: { id: true, displayName: true } }, + author: { select: { id: true, displayName: true, avatarPath: true } }, + tags: { include: { tag: true } }, }, }), prisma.post.count({ where }), ]); + const userVotes = new Map(); + if (req.user) { + const votes = await prisma.vote.findMany({ + where: { voterId: req.user.id, postId: { in: posts.map((p) => p.id) } }, + select: { postId: true, weight: true }, + }); + for (const v of votes) userVotes.set(v.postId, v.weight); + } + + const staleCutoff = board.staleDays > 0 + ? new Date(Date.now() - board.staleDays * 86400000) + : null; + reply.send({ posts: posts.map((p) => ({ id: p.id, type: p.type, title: p.title, + description: p.description, status: p.status, + statusReason: p.statusReason, category: p.category, voteCount: p.voteCount, + viewCount: p.viewCount, isPinned: p.isPinned, + isStale: staleCutoff ? p.lastActivityAt < staleCutoff : false, commentCount: p._count.comments, - author: p.author, + author: cleanAuthor(p.author), + tags: p.tags.map((pt) => pt.tag), + onBehalfOf: p.onBehalfOf, + voted: userVotes.has(p.id), + voteWeight: userVotes.get(p.id) ?? 0, createdAt: p.createdAt, updatedAt: p.updatedAt, })), total, page: q.page, pages: Math.ceil(total / q.limit), + staleDays: board.staleDays, }); } ); app.get<{ Params: { boardSlug: string; id: string } }>( "/boards/:boardSlug/posts/:id", - { preHandler: [app.optionalUser] }, + { preHandler: [app.optionalUser], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (req, reply) => { + const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + const post = await prisma.post.findUnique({ where: { id: req.params.id }, include: { - author: { select: { id: true, displayName: true } }, - _count: { select: { comments: true, votes: true } }, - adminResponses: { - include: { admin: { select: { id: true, email: true } } }, - orderBy: { createdAt: "asc" }, - }, + author: { select: { id: true, displayName: true, avatarPath: true } }, + _count: { select: { comments: true, votes: true, editHistory: true } }, statusChanges: { orderBy: { createdAt: "asc" } }, + tags: { include: { tag: true } }, + attachments: { select: { id: true, filename: true, mimeType: true, size: true } }, }, }); - if (!post) { + if (!post || post.boardId !== board.id) { + // check if this post was merged into another + const merge = await prisma.postMerge.findFirst({ + where: { sourcePostId: req.params.id }, + orderBy: { createdAt: "desc" }, + }); + if (merge) { + const targetPost = await prisma.post.findUnique({ where: { id: merge.targetPostId }, select: { boardId: true } }); + if (!targetPost || targetPost.boardId !== board.id) { + reply.status(404).send({ error: "Post not found" }); + return; + } + reply.send({ merged: true, targetPostId: merge.targetPostId }); + return; + } reply.status(404).send({ error: "Post not found" }); return; } + const viewKey = req.user?.id ?? req.ip; + if (shouldCount(post.id, viewKey)) { + prisma.post.update({ where: { id: post.id }, data: { viewCount: { increment: 1 } } }).catch(() => {}); + } + let voted = false; + let voteWeight = 0; + let userImportance: string | null = null; if (req.user) { const existing = await prisma.vote.findUnique({ where: { postId_voterId: { postId: post.id, voterId: req.user.id } }, }); voted = !!existing; + voteWeight = existing?.weight ?? 0; + userImportance = existing?.importance ?? null; } - reply.send({ ...post, voted }); + const importanceVotes = await prisma.vote.groupBy({ + by: ["importance"], + where: { postId: post.id, importance: { not: null } }, + _count: { importance: true }, + }); + + const importanceCounts: Record = { + critical: 0, important: 0, nice_to_have: 0, minor: 0, + }; + for (const row of importanceVotes) { + if (row.importance && row.importance in importanceCounts) { + importanceCounts[row.importance] = row._count.importance; + } + } + + reply.send({ + id: post.id, + type: post.type, + title: post.title, + description: post.description, + status: post.status, + statusReason: post.statusReason, + category: post.category, + voteCount: post.voteCount, + viewCount: post.viewCount, + isPinned: post.isPinned, + isEditLocked: post.isEditLocked, + isThreadLocked: post.isThreadLocked, + isVotingLocked: post.isVotingLocked, + onBehalfOf: post.onBehalfOf, + commentCount: post._count.comments, + voteTotal: post._count.votes, + author: cleanAuthor(post.author), + tags: post.tags.map((pt) => pt.tag), + attachments: post.attachments, + statusChanges: post.statusChanges.map((sc) => ({ + id: sc.id, + fromStatus: sc.fromStatus, + toStatus: sc.toStatus, + reason: sc.reason, + createdAt: sc.createdAt, + })), + voted, + voteWeight, + userImportance, + importanceCounts, + editCount: post._count.editHistory, + createdAt: post.createdAt, + updatedAt: post.updatedAt, + }); + } + ); + + app.get<{ Params: { boardSlug: string; id: string } }>( + "/boards/:boardSlug/posts/:id/timeline", + { preHandler: [app.optionalUser, app.optionalAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post || post.boardId !== board.id) { + reply.status(404).send({ error: "Post not found" }); + return; + } + + const [statusChanges, comments] = await Promise.all([ + prisma.statusChange.findMany({ + where: { postId: post.id }, + orderBy: { createdAt: "asc" }, + take: 500, + }), + prisma.comment.findMany({ + where: { postId: post.id }, + include: { + author: { select: { id: true, displayName: true, avatarPath: true } }, + adminUser: { select: { displayName: true, teamTitle: true } }, + reactions: { select: { emoji: true, userId: true } }, + attachments: { select: { id: true, filename: true, mimeType: true, size: true } }, + _count: { select: { editHistory: true } }, + replyTo: { + select: { + id: true, + body: true, + isAdmin: true, + adminUserId: true, + author: { select: { id: true, displayName: true, avatarPath: true } }, + adminUser: { select: { displayName: true } }, + }, + }, + }, + orderBy: { createdAt: "asc" }, + take: 500, + }), + ]); + + const entries = [ + ...statusChanges.map((sc) => ({ + id: sc.id, + type: "status_change" as const, + authorName: "System", + content: "", + oldStatus: sc.fromStatus, + newStatus: sc.toStatus, + reason: sc.reason ?? null, + createdAt: sc.createdAt, + isAdmin: true, + })), + ...comments.map((c) => { + const emojiMap: Record = {}; + for (const r of c.reactions) { + if (!emojiMap[r.emoji]) emojiMap[r.emoji] = { count: 0, userIds: [] }; + emojiMap[r.emoji].count++; + emojiMap[r.emoji].userIds.push(r.userId); + } + return { + id: c.id, + type: "comment" as const, + authorId: c.author?.id ?? null, + authorName: c.isAdmin ? (decryptName(c.adminUser?.displayName ?? null) ?? "Admin") : (decryptName(c.author?.displayName ?? null) ?? `Anonymous #${(c.author?.id ?? "0000").slice(-4)}`), + authorTitle: c.isAdmin && c.adminUser?.teamTitle ? decryptName(c.adminUser.teamTitle) : null, + authorAvatarUrl: c.author?.avatarPath ? `/api/v1/avatars/${c.author.id}` : null, + content: c.body, + createdAt: c.createdAt, + isAdmin: c.isAdmin, + replyTo: c.replyTo ? { + id: c.replyTo.id, + body: c.replyTo.body.slice(0, 200), + isAdmin: c.replyTo.isAdmin, + authorName: c.replyTo.isAdmin ? (decryptName(c.replyTo.adminUser?.displayName ?? null) ?? "Admin") : (decryptName(c.replyTo.author?.displayName ?? null) ?? `Anonymous #${(c.replyTo.author?.id ?? "0000").slice(-4)}`), + } : null, + reactions: Object.entries(emojiMap).map(([emoji, data]) => ({ + emoji, + count: data.count, + hasReacted: req.user ? data.userIds.includes(req.user.id) : false, + })), + attachments: c.attachments, + editCount: c._count.editHistory, + isEditLocked: c.isEditLocked, + }; + }), + ].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + + reply.send({ entries, isCurrentAdmin: !!req.adminId }); } ); app.post<{ Params: { boardSlug: string }; Body: z.infer }>( "/boards/:boardSlug/posts", - { preHandler: [app.requireUser] }, + { preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } }, async (req, reply) => { const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); if (!board || board.isArchived) { @@ -143,17 +480,59 @@ export default async function postRoutes(app: FastifyInstance) { return; } + const cleanTitle = body.title.replace(INVISIBLE_RE, ''); + const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const normalizedTitle = cleanTitle.trim().replace(/\s+/g, ' ').replace(/[.!?,;:]+$/, '').toLowerCase(); + const recentPosts = await prisma.post.findMany({ + where: { + boardId: board.id, + authorId: req.user!.id, + createdAt: { gte: dayAgo }, + }, + select: { title: true }, + take: 100, + }); + const isDuplicate = recentPosts.some(p => { + const norm = p.title.replace(INVISIBLE_RE, '').trim().replace(/\s+/g, ' ').replace(/[.!?,;:]+$/, '').toLowerCase(); + return norm === normalizedTitle; + }); + if (isDuplicate) { + reply.status(409).send({ error: "You already posted something similar within the last 24 hours" }); + return; + } + + if (body.templateId) { + const tmpl = await prisma.boardTemplate.findUnique({ where: { id: body.templateId } }); + if (!tmpl || tmpl.boardId !== board.id) { + reply.status(400).send({ error: "Invalid template" }); + return; + } + } + const post = await prisma.post.create({ data: { type: body.type, - title: body.title, + title: cleanTitle, description: body.description, category: body.category, + templateId: body.templateId, boardId: board.id, authorId: req.user!.id, }, }); + if (body.attachmentIds?.length) { + await prisma.attachment.updateMany({ + where: { + id: { in: body.attachmentIds }, + uploaderId: req.user!.id, + postId: null, + commentId: null, + }, + data: { postId: post.id }, + }); + } + await prisma.activityEvent.create({ data: { type: "post_created", @@ -163,16 +542,40 @@ export default async function postRoutes(app: FastifyInstance) { }, }); - reply.status(201).send(post); + fireWebhook("post_created", { + postId: post.id, + title: post.title, + type: post.type, + boardId: board.id, + boardSlug: board.slug, + }); + + notifyBoardSubscribers(board.id, { + title: `New post in ${board.name}`, + body: cleanTitle.slice(0, 100), + url: `/b/${board.slug}/post/${post.id}`, + tag: `board-${board.id}-new`, + }); + + reply.status(201).send({ + id: post.id, type: post.type, title: post.title, description: post.description, + status: post.status, category: post.category, createdAt: post.createdAt, + }); } ); app.put<{ Params: { boardSlug: string; id: string }; Body: z.infer }>( "/boards/:boardSlug/posts/:id", - { preHandler: [app.requireUser] }, + { preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, async (req, reply) => { + const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); - if (!post) { + if (!post || post.boardId !== board.id) { reply.status(404).send({ error: "Post not found" }); return; } @@ -181,26 +584,136 @@ export default async function postRoutes(app: FastifyInstance) { return; } + if (post.isEditLocked) { + reply.status(403).send({ error: "Editing is locked on this post" }); + return; + } + const body = updatePostSchema.parse(req.body); + + // admins skip ALTCHA, regular users must provide it + if (!req.adminId) { + if (!body.altcha) { + reply.status(400).send({ error: "Challenge response required" }); + return; + } + const valid = await verifyChallenge(body.altcha); + if (!valid) { + reply.status(400).send({ error: "Invalid challenge response" }); + return; + } + } + + if (body.description && !post.templateId) { + const dr = descriptionRecord.safeParse(body.description); + if (!dr.success) { + reply.status(400).send({ error: "Unknown description fields" }); + return; + } + const schema = post.type === PostType.BUG_REPORT ? bugReportSchema : featureRequestSchema; + const result = schema.safeParse(body.description); + if (!result.success) { + reply.status(400).send({ error: "Invalid description for this post type" }); + return; + } + } + if (body.description && post.templateId) { + const tr = templateDescRecord.safeParse(body.description); + if (!tr.success) { + reply.status(400).send({ error: "Too many description fields" }); + return; + } + const tmpl = await prisma.boardTemplate.findUnique({ where: { id: post.templateId } }); + if (!tmpl) { + reply.status(400).send({ error: "Template no longer exists" }); + return; + } + const templateKeys = new Set((tmpl.fields as any[]).map((f: any) => f.key)); + const submitted = Object.keys(body.description); + const unknown = submitted.filter((k) => !templateKeys.has(k)); + if (unknown.length > 0) { + reply.status(400).send({ error: "Unknown template fields: " + unknown.join(", ") }); + return; + } + } + + const titleChanged = body.title !== undefined && body.title !== post.title; + const descChanged = body.description !== undefined && JSON.stringify(body.description) !== JSON.stringify(post.description); + if (titleChanged || descChanged) { + await prisma.editHistory.create({ + data: { + postId: post.id, + editedBy: req.user!.id, + previousTitle: post.title, + previousDescription: post.description as any, + }, + }); + } + const updated = await prisma.post.update({ where: { id: post.id }, data: { - ...(body.title !== undefined && { title: body.title }), + ...(body.title !== undefined && { title: body.title.replace(INVISIBLE_RE, '') }), ...(body.description !== undefined && { description: body.description }), ...(body.category !== undefined && { category: body.category }), }, }); - reply.send(updated); + reply.send({ + id: updated.id, type: updated.type, title: updated.title, + description: updated.description, status: updated.status, + category: updated.category, updatedAt: updated.updatedAt, + }); + } + ); + + app.get<{ Params: { boardSlug: string; id: string } }>( + "/boards/:boardSlug/posts/:id/edits", + { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post || post.boardId !== board.id) { + reply.status(404).send({ error: "Post not found" }); + return; + } + + const edits = await prisma.editHistory.findMany({ + where: { postId: post.id }, + orderBy: { createdAt: "desc" }, + take: 50, + include: { + editor: { select: { id: true, displayName: true, avatarPath: true } }, + }, + }); + + reply.send(edits.map((e) => ({ + id: e.id, + previousTitle: e.previousTitle, + previousDescription: e.previousDescription, + editedBy: cleanAuthor(e.editor), + createdAt: e.createdAt, + }))); } ); app.delete<{ Params: { boardSlug: string; id: string } }>( "/boards/:boardSlug/posts/:id", - { preHandler: [app.requireUser] }, + { preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => { + const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); - if (!post) { + if (!post || post.boardId !== board.id) { reply.status(404).send({ error: "Post not found" }); return; } @@ -209,7 +722,36 @@ export default async function postRoutes(app: FastifyInstance) { return; } + const commentIds = (await prisma.comment.findMany({ + where: { postId: post.id }, + select: { id: true }, + })).map((c) => c.id); + + const attachments = await prisma.attachment.findMany({ + where: { + OR: [ + { postId: post.id }, + ...(commentIds.length ? [{ commentId: { in: commentIds } }] : []), + ], + }, + select: { id: true, path: true }, + }); + + if (attachments.length) { + await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } }); + } + + await prisma.postMerge.deleteMany({ + where: { OR: [{ sourcePostId: post.id }, { targetPostId: post.id }] }, + }); + await prisma.post.delete({ where: { id: post.id } }); + + const uploadDir = resolve(process.cwd(), "uploads"); + for (const att of attachments) { + await unlink(resolve(uploadDir, att.path)).catch(() => {}); + } + reply.status(204).send(); } ); diff --git a/packages/api/src/routes/privacy.ts b/packages/api/src/routes/privacy.ts index a613d0b..d0899d2 100644 --- a/packages/api/src/routes/privacy.ts +++ b/packages/api/src/routes/privacy.ts @@ -1,47 +1,69 @@ import { FastifyInstance } from "fastify"; -import { PrismaClient } from "@prisma/client"; +import { z } from "zod"; +import prisma from "../lib/prisma.js"; import { generateChallenge } from "../services/altcha.js"; import { config } from "../config.js"; -const prisma = new PrismaClient(); +const challengeQuery = z.object({ + difficulty: z.enum(["normal", "light"]).default("normal"), +}); export default async function privacyRoutes(app: FastifyInstance) { - app.get("/altcha/challenge", async (req, reply) => { - const difficulty = req.query && (req.query as any).difficulty === "light" ? "light" : "normal"; - const challenge = await generateChallenge(difficulty as "normal" | "light"); + app.get("/altcha/challenge", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => { + const { difficulty } = challengeQuery.parse(req.query); + const challenge = await generateChallenge(difficulty); reply.send(challenge); }); - app.get("/privacy/data-manifest", async (_req, reply) => { + app.get("/privacy/data-manifest", { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (_req, reply) => { reply.send({ - dataCollected: { - anonymous: { - cookieToken: "SHA-256 hashed, used for session identity", - displayName: "AES-256-GCM encrypted, optional", - posts: "Stored with author reference, deletable", - comments: "Stored with author reference, deletable", - votes: "Stored with voter reference, deletable", - reactions: "Stored with user reference, deletable", - }, - passkey: { - username: "AES-256-GCM encrypted with blind index", - credentialId: "AES-256-GCM encrypted with blind index", - publicKey: "Encrypted at rest", - }, + anonymousUser: [ + { field: "Token hash", purpose: "SHA-256 hashed, used for session identity", retention: "Until user deletes", deletable: true }, + { field: "Display name", purpose: "AES-256-GCM encrypted, optional cosmetic name", retention: "Until user deletes", deletable: true }, + { field: "Dark mode preference", purpose: "Theme setting (system/light/dark)", retention: "Until user deletes", deletable: true }, + { field: "Posts", purpose: "Stored with author reference", retention: "Until user deletes (anonymized)", deletable: true }, + { field: "Comments", purpose: "Stored with author reference", retention: "Until user deletes (anonymized)", deletable: true }, + { field: "Votes", purpose: "Stored with voter reference", retention: "Until user deletes", deletable: true }, + { field: "Reactions", purpose: "Emoji reactions on comments", retention: "Until user deletes", deletable: true }, + ], + passkeyUser: [ + { field: "Username", purpose: "AES-256-GCM encrypted with HMAC-SHA256 blind index", retention: "Until user deletes", deletable: true }, + { field: "Display name", purpose: "AES-256-GCM encrypted", retention: "Until user deletes", deletable: true }, + { field: "Avatar image", purpose: "Optional profile photo stored on disk", retention: "Until user deletes", deletable: true }, + { field: "Credential ID", purpose: "AES-256-GCM encrypted with blind index", retention: "Until user deletes", deletable: true }, + { field: "Public key", purpose: "AES-256-GCM encrypted", retention: "Until user deletes", deletable: true }, + { field: "Counter", purpose: "Replay detection (not PII)", retention: "Until user deletes", deletable: true }, + { field: "Device type", purpose: "singleDevice or multiDevice (not PII)", retention: "Until user deletes", deletable: true }, + { field: "Backed up flag", purpose: "Whether passkey is synced (not PII)", retention: "Until user deletes", deletable: true }, + { field: "Transports", purpose: "AES-256-GCM encrypted", retention: "Until user deletes", deletable: true }, + ], + cookieInfo: "This site uses exactly one cookie. It contains a random identifier used to connect you to your posts. It is not used for tracking, analytics, or advertising. No other cookies are set, ever.", + dataLocation: "Self-hosted PostgreSQL, encrypted at rest", + thirdParties: [], + neverStored: [ + "Email address", "IP address", "Browser fingerprint", "User-agent string", + "Referrer URL", "Geolocation", "Device identifiers", "Behavioral data", + "Session replays", "Third-party tracking identifiers", + ], + securityHeaders: { + "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-src 'none'; object-src 'none'", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload", + "Permissions-Policy": "camera=(), microphone=(), geolocation=(), interest-cohort=()", + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Resource-Policy": "same-origin", + "X-DNS-Prefetch-Control": "off", }, retention: { activityEvents: `${config.DATA_RETENTION_ACTIVITY_DAYS} days`, orphanedUsers: `${config.DATA_RETENTION_ORPHAN_USER_DAYS} days`, }, - encryption: "AES-256-GCM with 96-bit random IV per value", - indexing: "HMAC-SHA256 blind indexes for lookups", - thirdParty: "None - fully self-hosted", - export: "GET /api/v1/me/export", - deletion: "DELETE /api/v1/me", }); }); - app.get("/categories", async (_req, reply) => { + app.get("/categories", { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (_req, reply) => { const cats = await prisma.category.findMany({ orderBy: { name: "asc" }, }); diff --git a/packages/api/src/routes/push.ts b/packages/api/src/routes/push.ts index 434003c..ccd58b3 100644 --- a/packages/api/src/routes/push.ts +++ b/packages/api/src/routes/push.ts @@ -1,16 +1,32 @@ import { FastifyInstance } from "fastify"; -import { PrismaClient } from "@prisma/client"; import { z } from "zod"; +import prisma from "../lib/prisma.js"; import { encrypt, blindIndex } from "../services/encryption.js"; import { masterKey, blindIndexKey } from "../config.js"; -const prisma = new PrismaClient(); +const PUSH_DOMAINS = [ + "fcm.googleapis.com", + "updates.push.services.mozilla.com", + "notify.windows.com", + "push.apple.com", + "web.push.apple.com", +]; + +function isValidPushEndpoint(url: string): boolean { + try { + const parsed = new URL(url); + if (parsed.protocol !== "https:") return false; + return PUSH_DOMAINS.some((d) => parsed.hostname === d || parsed.hostname.endsWith("." + d)); + } catch { + return false; + } +} const subscribeBody = z.object({ - endpoint: z.string().url(), + endpoint: z.string().url().refine(isValidPushEndpoint, "Must be a known push service endpoint"), keys: z.object({ - p256dh: z.string(), - auth: z.string(), + p256dh: z.string().max(200), + auth: z.string().max(200), }), boardId: z.string().optional(), postId: z.string().optional(), @@ -21,15 +37,44 @@ const unsubscribeBody = z.object({ }); export default async function pushRoutes(app: FastifyInstance) { + app.get( + "/push/vapid", + { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, + async (_req, reply) => { + const publicKey = process.env.VAPID_PUBLIC_KEY ?? ""; + reply.send({ publicKey }); + } + ); + app.post<{ Body: z.infer }>( "/push/subscribe", - { preHandler: [app.requireUser] }, + { preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, async (req, reply) => { const body = subscribeBody.parse(req.body); + + if (body.boardId) { + const board = await prisma.board.findUnique({ where: { id: body.boardId }, select: { id: true } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + } + if (body.postId) { + const post = await prisma.post.findUnique({ where: { id: body.postId }, select: { id: true } }); + if (!post) { + reply.status(404).send({ error: "Post not found" }); + return; + } + } + const endpointIdx = blindIndex(body.endpoint, blindIndexKey); const existing = await prisma.pushSubscription.findUnique({ where: { endpointIdx } }); if (existing) { + if (existing.userId !== req.user!.id) { + reply.status(403).send({ error: "Not your subscription" }); + return; + } await prisma.pushSubscription.update({ where: { id: existing.id }, data: { @@ -41,6 +86,12 @@ export default async function pushRoutes(app: FastifyInstance) { return; } + const subCount = await prisma.pushSubscription.count({ where: { userId: req.user!.id } }); + if (subCount >= 50) { + reply.status(400).send({ error: "Maximum 50 push subscriptions" }); + return; + } + await prisma.pushSubscription.create({ data: { endpoint: encrypt(body.endpoint, masterKey), @@ -59,7 +110,7 @@ export default async function pushRoutes(app: FastifyInstance) { app.delete<{ Body: z.infer }>( "/push/subscribe", - { preHandler: [app.requireUser] }, + { preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, async (req, reply) => { const body = unsubscribeBody.parse(req.body); const endpointIdx = blindIndex(body.endpoint, blindIndexKey); @@ -79,7 +130,7 @@ export default async function pushRoutes(app: FastifyInstance) { app.get( "/push/subscriptions", - { preHandler: [app.requireUser] }, + { preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => { const subs = await prisma.pushSubscription.findMany({ where: { userId: req.user!.id }, diff --git a/packages/api/src/routes/reactions.ts b/packages/api/src/routes/reactions.ts index e1bf423..72e3b02 100644 --- a/packages/api/src/routes/reactions.ts +++ b/packages/api/src/routes/reactions.ts @@ -1,17 +1,15 @@ import { FastifyInstance } from "fastify"; -import { PrismaClient } from "@prisma/client"; import { z } from "zod"; - -const prisma = new PrismaClient(); +import prisma from "../lib/prisma.js"; const reactionBody = z.object({ - emoji: z.string().min(1).max(8), + emoji: z.string().min(1).max(8).regex(/^[\p{Extended_Pictographic}\u{FE0F}\u{200D}]+$/u, "Invalid emoji"), }); export default async function reactionRoutes(app: FastifyInstance) { app.post<{ Params: { id: string }; Body: z.infer }>( "/comments/:id/reactions", - { preHandler: [app.requireUser] }, + { preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, async (req, reply) => { const comment = await prisma.comment.findUnique({ where: { id: req.params.id } }); if (!comment) { @@ -19,6 +17,19 @@ export default async function reactionRoutes(app: FastifyInstance) { return; } + const post = await prisma.post.findUnique({ + where: { id: comment.postId }, + select: { isThreadLocked: true, board: { select: { isArchived: true } } }, + }); + if (post?.board?.isArchived) { + reply.status(403).send({ error: "Board is archived" }); + return; + } + if (post?.isThreadLocked) { + reply.status(403).send({ error: "Thread is locked" }); + return; + } + const { emoji } = reactionBody.parse(req.body); const existing = await prisma.reaction.findUnique({ @@ -35,6 +46,14 @@ export default async function reactionRoutes(app: FastifyInstance) { await prisma.reaction.delete({ where: { id: existing.id } }); reply.send({ toggled: false }); } else { + const distinctCount = await prisma.reaction.count({ + where: { commentId: comment.id, userId: req.user!.id }, + }); + if (distinctCount >= 10) { + reply.status(400).send({ error: "Too many reactions" }); + return; + } + await prisma.reaction.create({ data: { emoji, @@ -49,8 +68,33 @@ export default async function reactionRoutes(app: FastifyInstance) { app.delete<{ Params: { id: string; emoji: string } }>( "/comments/:id/reactions/:emoji", - { preHandler: [app.requireUser] }, + { preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, async (req, reply) => { + const emoji = req.params.emoji; + if (!emoji || emoji.length > 8 || !/^[\p{Extended_Pictographic}\u{FE0F}\u{200D}]+$/u.test(emoji)) { + reply.status(400).send({ error: "Invalid emoji" }); + return; + } + + const comment = await prisma.comment.findUnique({ where: { id: req.params.id }, select: { id: true, postId: true } }); + if (!comment) { + reply.status(404).send({ error: "Comment not found" }); + return; + } + + const post = await prisma.post.findUnique({ + where: { id: comment.postId }, + select: { isThreadLocked: true, board: { select: { isArchived: true } } }, + }); + if (post?.board?.isArchived) { + reply.status(403).send({ error: "Board is archived" }); + return; + } + if (post?.isThreadLocked) { + reply.status(403).send({ error: "Thread is locked" }); + return; + } + const deleted = await prisma.reaction.deleteMany({ where: { commentId: req.params.id, diff --git a/packages/api/src/routes/recovery.ts b/packages/api/src/routes/recovery.ts new file mode 100644 index 0000000..a854a0e --- /dev/null +++ b/packages/api/src/routes/recovery.ts @@ -0,0 +1,142 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { randomBytes } from "node:crypto"; +import bcrypt from "bcrypt"; +import prisma from "../lib/prisma.js"; +import { blindIndex, hashToken } from "../services/encryption.js"; +import { blindIndexKey } from "../config.js"; +import { verifyChallenge } from "../services/altcha.js"; +import { generateRecoveryPhrase } from "../lib/wordlist.js"; + +const EXPIRY_DAYS = 90; + +const recoverBody = z.object({ + phrase: z.string().regex(/^[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+$/), + altcha: z.string(), +}); + +async function getFailedAttempts(ip: string): Promise { + const cutoff = new Date(Date.now() - 15 * 60 * 1000); + const count = await prisma.blockedToken.count({ + where: { tokenHash: { startsWith: `recovery:${ip}:` }, createdAt: { gt: cutoff } }, + }); + return count; +} + +async function recordFailedAttempt(ip: string): Promise { + await prisma.blockedToken.create({ + data: { + tokenHash: `recovery:${ip}:${Date.now()}`, + expiresAt: new Date(Date.now() + 15 * 60 * 1000), + }, + }); +} + +export default async function recoveryRoutes(app: FastifyInstance) { + // check if user has a recovery code + app.get( + "/me/recovery-code", + { preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, + async (req, reply) => { + const code = await prisma.recoveryCode.findUnique({ + where: { userId: req.user!.id }, + select: { expiresAt: true }, + }); + if (code && code.expiresAt > new Date()) { + reply.send({ hasCode: true, expiresAt: code.expiresAt }); + } else { + reply.send({ hasCode: false }); + } + } + ); + + // generate a new recovery code + app.post( + "/me/recovery-code", + { preHandler: [app.requireUser], config: { rateLimit: { max: 3, timeWindow: "1 hour" } } }, + async (req, reply) => { + if (req.user!.authMethod === "PASSKEY") { + reply.status(400).send({ error: "Passkey users don't need recovery codes" }); + return; + } + + const phrase = generateRecoveryPhrase(); + const codeHash = await bcrypt.hash(phrase, 12); + const phraseIdx = blindIndex(phrase, blindIndexKey); + const expiresAt = new Date(Date.now() + EXPIRY_DAYS * 24 * 60 * 60 * 1000); + + await prisma.recoveryCode.upsert({ + where: { userId: req.user!.id }, + create: { codeHash, phraseIdx, userId: req.user!.id, expiresAt }, + update: { codeHash, phraseIdx, expiresAt }, + }); + + reply.send({ phrase, expiresAt }); + } + ); + + // recover identity using a code (unauthenticated) + app.post( + "/auth/recover", + { config: { rateLimit: { max: 3, timeWindow: "15 minutes" } } }, + async (req, reply) => { + const body = recoverBody.parse(req.body); + + const valid = await verifyChallenge(body.altcha); + if (!valid) { + reply.status(400).send({ error: "Invalid challenge response" }); + return; + } + + // exponential backoff per IP (persistent) + const attempts = await getFailedAttempts(req.ip); + if (attempts >= 3) { + const delay = Math.min(1000 * Math.pow(2, attempts - 3), 30000); + await new Promise((r) => setTimeout(r, delay)); + } + + const phrase = body.phrase.toLowerCase().trim(); + const idx = blindIndex(phrase, blindIndexKey); + + const record = await prisma.recoveryCode.findUnique({ + where: { phraseIdx: idx }, + include: { user: { select: { id: true } } }, + }); + + if (!record || record.expiresAt < new Date()) { + await recordFailedAttempt(req.ip); + reply.status(401).send({ error: "Invalid or expired recovery code" }); + return; + } + + const matches = await bcrypt.compare(phrase, record.codeHash); + if (!matches) { + await recordFailedAttempt(req.ip); + reply.status(401).send({ error: "Invalid or expired recovery code" }); + return; + } + + // success - issue new session token and delete used code + const token = randomBytes(32).toString("hex"); + const hash = hashToken(token); + + await prisma.$transaction([ + prisma.user.update({ + where: { id: record.userId }, + data: { tokenHash: hash }, + }), + prisma.recoveryCode.delete({ where: { id: record.id } }), + ]); + + reply + .setCookie("echoboard_token", token, { + path: "/", + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 24 * 90, + }) + .send({ recovered: true }); + } + ); +} diff --git a/packages/api/src/routes/roadmap.ts b/packages/api/src/routes/roadmap.ts new file mode 100644 index 0000000..e82ae4e --- /dev/null +++ b/packages/api/src/routes/roadmap.ts @@ -0,0 +1,54 @@ +import { FastifyInstance } from "fastify"; +import { prisma } from "../lib/prisma.js"; + +const ROADMAP_STATUSES = ["PLANNED", "IN_PROGRESS", "DONE"] as const; + +export default async function roadmapRoutes(app: FastifyInstance) { + const handler = async (req: any, reply: any) => { + const boardSlug = req.params?.boardSlug || (req.query as any)?.board || null; + + const where: any = { + status: { in: [...ROADMAP_STATUSES] }, + board: { isArchived: false }, + }; + if (boardSlug) { + where.board.slug = boardSlug; + } + + const posts = await prisma.post.findMany({ + where, + select: { + id: true, + title: true, + type: true, + status: true, + category: true, + voteCount: true, + createdAt: true, + board: { select: { slug: true, name: true } }, + _count: { select: { comments: true } }, + tags: { include: { tag: true } }, + }, + orderBy: [{ voteCount: "desc" }, { createdAt: "desc" }], + take: 200, + }); + + const mapped = posts.map((p) => ({ + ...p, + tags: p.tags.map((pt) => pt.tag), + })); + + const columns = { + PLANNED: mapped.filter((p) => p.status === "PLANNED"), + IN_PROGRESS: mapped.filter((p) => p.status === "IN_PROGRESS"), + DONE: mapped.filter((p) => p.status === "DONE"), + }; + + reply.send({ columns }); + }; + + const opts = { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }; + + app.get("/roadmap", opts, handler); + app.get("/b/:boardSlug/roadmap", opts, handler); +} diff --git a/packages/api/src/routes/search.ts b/packages/api/src/routes/search.ts new file mode 100644 index 0000000..3bee39f --- /dev/null +++ b/packages/api/src/routes/search.ts @@ -0,0 +1,109 @@ +import { FastifyInstance } from "fastify"; +import { prisma } from "../lib/prisma.js"; +import { z } from "zod"; +import { decrypt } from "../services/encryption.js"; +import { masterKey } from "../config.js"; + +const searchQuery = z.object({ + q: z.string().min(1).max(200), +}); + +function decryptName(encrypted: string | null): string | null { + if (!encrypted) return null; + try { return decrypt(encrypted, masterKey); } catch { return null; } +} + +export default async function searchRoutes(app: FastifyInstance) { + app.get<{ Querystring: { q: string } }>("/search", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => { + const { q } = searchQuery.parse(req.query); + const term = q.trim(); + const likeTerm = `%${term}%`; + + const [boardMatches, postMatches] = await Promise.all([ + prisma.$queryRaw<{ id: string; slug: string; name: string; icon_name: string | null; icon_color: string | null; description: string | null; post_count: number }[]>` + SELECT id, slug, name, "iconName" as icon_name, "iconColor" as icon_color, description, + (SELECT COUNT(*)::int FROM "Post" WHERE "boardId" = "Board".id) as post_count + FROM "Board" + WHERE "isArchived" = false + AND ( + word_similarity(${term}, name) > 0.15 + OR word_similarity(${term}, COALESCE(description, '')) > 0.15 + OR to_tsvector('english', name || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', ${term}) + ) + ORDER BY ( + word_similarity(${term}, name) + + CASE WHEN to_tsvector('english', name || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', ${term}) + THEN 1.0 ELSE 0.0 END + ) DESC + LIMIT 5 + `, + + prisma.$queryRaw<{ id: string }[]>` + SELECT p.id + FROM "Post" p + JOIN "Board" b ON b.id = p."boardId" + WHERE + word_similarity(${term}, p.title) > 0.15 + OR to_tsvector('english', p.title) @@ plainto_tsquery('english', ${term}) + OR p.description::text ILIKE ${likeTerm} + ORDER BY ( + word_similarity(${term}, p.title) + + CASE WHEN to_tsvector('english', p.title) @@ plainto_tsquery('english', ${term}) + THEN 1.0 ELSE 0.0 END + ) DESC, p."voteCount" DESC + LIMIT 15 + `, + ]); + + const postIds = postMatches.map((p) => p.id); + const posts = postIds.length + ? await prisma.post.findMany({ + where: { id: { in: postIds } }, + include: { + board: { select: { slug: true, name: true, iconName: true, iconColor: true } }, + author: { select: { id: true, displayName: true, avatarPath: true } }, + _count: { select: { comments: true } }, + }, + }) + : []; + + // preserve relevance ordering from raw SQL + const postOrder = new Map(postIds.map((id, i) => [id, i])); + posts.sort((a, b) => (postOrder.get(a.id) ?? 0) - (postOrder.get(b.id) ?? 0)); + + const boards = boardMatches.map((b) => ({ + type: "board" as const, + id: b.id, + title: b.name, + slug: b.slug, + iconName: b.icon_name, + iconColor: b.icon_color, + description: b.description, + postCount: b.post_count, + })); + + const postResults = posts.map((p) => ({ + type: "post" as const, + id: p.id, + title: p.title, + postType: p.type, + status: p.status, + voteCount: p.voteCount, + commentCount: p._count.comments, + boardSlug: p.board.slug, + boardName: p.board.name, + boardIconName: p.board.iconName, + boardIconColor: p.board.iconColor, + author: p.author + ? { + id: p.author.id, + displayName: decryptName(p.author.displayName), + avatarUrl: p.author.avatarPath ? `/api/v1/avatars/${p.author.id}` : null, + } + : null, + createdAt: p.createdAt, + })); + + reply.send({ boards, posts: postResults }); + }); +} diff --git a/packages/api/src/routes/similar.ts b/packages/api/src/routes/similar.ts new file mode 100644 index 0000000..1d1ed40 --- /dev/null +++ b/packages/api/src/routes/similar.ts @@ -0,0 +1,54 @@ +import { FastifyInstance } from "fastify"; +import { prisma } from "../lib/prisma.js"; +import { z } from "zod"; + +const similarQuery = z.object({ + title: z.string().min(5).max(200), + boardId: z.string().min(1), +}); + +export default async function similarRoutes(app: FastifyInstance) { + app.get<{ Querystring: Record }>( + "/similar", + { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, + async (req, reply) => { + const q = similarQuery.safeParse(req.query); + if (!q.success) { + reply.send({ posts: [] }); + return; + } + + const { title, boardId } = q.data; + + const board = await prisma.board.findUnique({ where: { id: boardId }, select: { id: true } }); + if (!board) { + reply.send({ posts: [] }); + return; + } + + // use pg_trgm similarity to find posts with similar titles + const similar = await prisma.$queryRaw< + { id: string; title: string; status: string; vote_count: number; similarity: number }[] + >` + SELECT id, title, status, "voteCount" as vote_count, + similarity(title, ${title}) as similarity + FROM "Post" + WHERE "boardId" = ${boardId} + AND similarity(title, ${title}) > 0.25 + AND status NOT IN ('DONE', 'DECLINED') + ORDER BY similarity DESC + LIMIT 5 + `; + + reply.send({ + posts: similar.map((p) => ({ + id: p.id, + title: p.title, + status: p.status, + voteCount: p.vote_count, + similarity: Math.round(p.similarity * 100), + })), + }); + } + ); +} diff --git a/packages/api/src/routes/templates.ts b/packages/api/src/routes/templates.ts new file mode 100644 index 0000000..1ef5228 --- /dev/null +++ b/packages/api/src/routes/templates.ts @@ -0,0 +1,21 @@ +import { FastifyInstance } from "fastify"; +import { prisma } from "../lib/prisma.js"; + +export default async function templateRoutes(app: FastifyInstance) { + app.get<{ Params: { boardSlug: string } }>( + "/boards/:boardSlug/templates", + { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); + if (!board) return reply.status(404).send({ error: "Board not found" }); + + const templates = await prisma.boardTemplate.findMany({ + where: { boardId: board.id }, + orderBy: { position: "asc" }, + select: { id: true, name: true, fields: true, isDefault: true, position: true }, + }); + + reply.send({ templates }); + } + ); +} diff --git a/packages/api/src/routes/votes.ts b/packages/api/src/routes/votes.ts index 9ebe597..a41a991 100644 --- a/packages/api/src/routes/votes.ts +++ b/packages/api/src/routes/votes.ts @@ -1,19 +1,134 @@ import { FastifyInstance } from "fastify"; -import { PrismaClient } from "@prisma/client"; import { z } from "zod"; +import prisma from "../lib/prisma.js"; import { verifyChallenge } from "../services/altcha.js"; import { getCurrentPeriod, getRemainingBudget, getNextResetDate } from "../lib/budget.js"; -const prisma = new PrismaClient(); - const voteBody = z.object({ altcha: z.string(), }); +const importanceBody = z.object({ + importance: z.enum(["critical", "important", "nice_to_have", "minor"]), +}); + export default async function voteRoutes(app: FastifyInstance) { app.post<{ Params: { boardSlug: string; id: string }; Body: z.infer }>( "/boards/:boardSlug/posts/:id/vote", - { preHandler: [app.requireUser] }, + { preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 hour" } } }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + if (board.isArchived) { + reply.status(403).send({ error: "Board is archived" }); + return; + } + + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post || post.boardId !== board.id) { + reply.status(404).send({ error: "Post not found" }); + return; + } + + if (post.authorId === req.user!.id) { + reply.status(403).send({ error: "Cannot vote on your own post" }); + return; + } + + if (post.isVotingLocked) { + reply.status(403).send({ error: "Voting is locked on this post" }); + return; + } + + const body = voteBody.parse(req.body); + const valid = await verifyChallenge(body.altcha); + if (!valid) { + reply.status(400).send({ error: "Invalid challenge response" }); + return; + } + + const period = getCurrentPeriod(board.voteBudgetReset); + + try { + await prisma.$transaction(async (tx) => { + const existing = await tx.vote.findUnique({ + where: { postId_voterId: { postId: post.id, voterId: req.user!.id } }, + }); + + if (existing && !board.allowMultiVote) throw new Error("ALREADY_VOTED"); + + const remaining = await getRemainingBudget(req.user!.id, board.id, tx); + if (remaining <= 0) throw new Error("BUDGET_EXHAUSTED"); + + if (existing && board.allowMultiVote) { + if (existing.weight >= 3) throw new Error("MAX_VOTES"); + await tx.vote.update({ + where: { id: existing.id }, + data: { weight: { increment: 1 } }, + }); + } else { + await tx.vote.create({ + data: { + postId: post.id, + voterId: req.user!.id, + budgetPeriod: period, + }, + }); + } + + await tx.post.update({ + where: { id: post.id }, + data: { voteCount: { increment: 1 }, lastActivityAt: new Date() }, + }); + }, { isolationLevel: "Serializable" }); + } catch (err: any) { + if (err.message === "ALREADY_VOTED") { + reply.status(409).send({ error: "Already voted" }); + return; + } + if (err.message === "BUDGET_EXHAUSTED") { + reply.status(429).send({ error: "Vote budget exhausted" }); + return; + } + if (err.message === "MAX_VOTES") { + reply.status(409).send({ error: "Max 3 votes per post" }); + return; + } + throw err; + } + + await prisma.activityEvent.create({ + data: { + type: "vote_cast", + boardId: board.id, + postId: post.id, + metadata: {}, + }, + }); + + const newCount = post.voteCount + 1; + const milestones = [10, 50, 100, 250, 500]; + if (milestones.includes(newCount)) { + await prisma.activityEvent.create({ + data: { + type: "vote_milestone", + boardId: board.id, + postId: post.id, + metadata: { milestoneCount: newCount, title: post.title }, + }, + }); + } + + reply.send({ ok: true, voteCount: newCount }); + } + ); + + app.delete<{ Params: { boardSlug: string; id: string } }>( + "/boards/:boardSlug/posts/:id/vote", + { preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 hour" } } }, async (req, reply) => { const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); if (!board) { @@ -27,66 +142,35 @@ export default async function voteRoutes(app: FastifyInstance) { return; } - const body = voteBody.parse(req.body); - const valid = await verifyChallenge(body.altcha); - if (!valid) { - reply.status(400).send({ error: "Invalid challenge response" }); + if (post.isVotingLocked) { + reply.status(403).send({ error: "Voting is locked on this post" }); return; } - const existing = await prisma.vote.findUnique({ - where: { postId_voterId: { postId: post.id, voterId: req.user!.id } }, - }); - - if (existing && !board.allowMultiVote) { - reply.status(409).send({ error: "Already voted" }); - return; - } - - const remaining = await getRemainingBudget(req.user!.id, board.id); - if (remaining <= 0) { - reply.status(429).send({ error: "Vote budget exhausted" }); - return; - } - - const period = getCurrentPeriod(board.voteBudgetReset); - - if (existing && board.allowMultiVote) { - await prisma.vote.update({ - where: { id: existing.id }, - data: { weight: existing.weight + 1 }, + await prisma.$transaction(async (tx) => { + const vote = await tx.vote.findUnique({ + where: { postId_voterId: { postId: req.params.id, voterId: req.user!.id } }, }); - } else { - await prisma.vote.create({ - data: { - postId: post.id, - voterId: req.user!.id, - budgetPeriod: period, - }, - }); - } + if (!vote) throw new Error("NO_VOTE"); - await prisma.post.update({ - where: { id: post.id }, - data: { voteCount: { increment: 1 } }, + await tx.vote.delete({ where: { id: vote.id } }); + await tx.$executeRaw`UPDATE "Post" SET "voteCount" = GREATEST(0, "voteCount" - ${vote.weight}) WHERE "id" = ${vote.postId}`; + }).catch((err) => { + if (err.message === "NO_VOTE") { + reply.status(404).send({ error: "No vote found" }); + return; + } + throw err; }); + if (reply.sent) return; - await prisma.activityEvent.create({ - data: { - type: "vote_cast", - boardId: board.id, - postId: post.id, - metadata: {}, - }, - }); - - reply.send({ ok: true, voteCount: post.voteCount + 1 }); + reply.send({ ok: true }); } ); - app.delete<{ Params: { boardSlug: string; id: string } }>( - "/boards/:boardSlug/posts/:id/vote", - { preHandler: [app.requireUser] }, + app.put<{ Params: { boardSlug: string; id: string }; Body: z.infer }>( + "/boards/:boardSlug/posts/:id/vote/importance", + { preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => { const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); if (!board) { @@ -94,6 +178,18 @@ export default async function voteRoutes(app: FastifyInstance) { return; } + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post || post.boardId !== board.id) { + reply.status(404).send({ error: "Post not found" }); + return; + } + if (post.isVotingLocked) { + reply.status(403).send({ error: "Voting is locked" }); + return; + } + + const body = importanceBody.parse(req.body); + const vote = await prisma.vote.findUnique({ where: { postId_voterId: { postId: req.params.id, voterId: req.user!.id } }, }); @@ -103,11 +199,9 @@ export default async function voteRoutes(app: FastifyInstance) { return; } - const weight = vote.weight; - await prisma.vote.delete({ where: { id: vote.id } }); - await prisma.post.update({ - where: { id: req.params.id }, - data: { voteCount: { decrement: weight } }, + await prisma.vote.update({ + where: { id: vote.id }, + data: { importance: body.importance }, }); reply.send({ ok: true }); @@ -116,7 +210,7 @@ export default async function voteRoutes(app: FastifyInstance) { app.get<{ Params: { boardSlug: string } }>( "/boards/:boardSlug/budget", - { preHandler: [app.requireUser] }, + { preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => { const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); if (!board) { diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index e8fab65..9454b48 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -1,14 +1,19 @@ -import Fastify from "fastify"; +import Fastify, { FastifyError } from "fastify"; import cookie from "@fastify/cookie"; import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; import fastifyStatic from "@fastify/static"; +import multipart from "@fastify/multipart"; import { resolve } from "node:path"; import { existsSync } from "node:fs"; +import { createHmac } from "node:crypto"; +import { config } from "./config.js"; +import prisma from "./lib/prisma.js"; import securityPlugin from "./middleware/security.js"; import authPlugin from "./middleware/auth.js"; -import { loadPlugins } from "./plugins/loader.js"; +import { loadPlugins, startupPlugins, shutdownPlugins, getActivePluginInfo, getPluginAdminRoutes } from "./plugins/loader.js"; +import { seedAllBoardTemplates } from "./lib/default-templates.js"; import boardRoutes from "./routes/boards.js"; import postRoutes from "./routes/posts.js"; @@ -26,6 +31,25 @@ import adminPostRoutes from "./routes/admin/posts.js"; import adminBoardRoutes from "./routes/admin/boards.js"; import adminCategoryRoutes from "./routes/admin/categories.js"; import adminStatsRoutes from "./routes/admin/stats.js"; +import searchRoutes from "./routes/search.js"; +import roadmapRoutes from "./routes/roadmap.js"; +import similarRoutes from "./routes/similar.js"; +import adminTagRoutes from "./routes/admin/tags.js"; +import adminNoteRoutes from "./routes/admin/notes.js"; +import adminChangelogRoutes from "./routes/admin/changelog.js"; +import adminWebhookRoutes from "./routes/admin/webhooks.js"; +import changelogRoutes from "./routes/changelog.js"; +import notificationRoutes from "./routes/notifications.js"; +import embedRoutes from "./routes/embed.js"; +import adminStatusRoutes from "./routes/admin/statuses.js"; +import adminExportRoutes from "./routes/admin/export.js"; +import adminTemplateRoutes from "./routes/admin/templates.js"; +import templateRoutes from "./routes/templates.js"; +import attachmentRoutes from "./routes/attachments.js"; +import avatarRoutes from "./routes/avatars.js"; +import recoveryRoutes from "./routes/recovery.js"; +import settingsRoutes from "./routes/admin/settings.js"; +import adminTeamRoutes from "./routes/admin/team.js"; export async function createServer() { const app = Fastify({ @@ -35,25 +59,70 @@ export async function createServer() { return { method: req.method, url: req.url, + remoteAddress: req.ip, }; }, }, }, }); - await app.register(cookie, { secret: process.env.TOKEN_SECRET }); + const cookieSecret = createHmac("sha256", config.TOKEN_SECRET).update("echoboard:cookie").digest("hex"); + await app.register(cookie, { secret: cookieSecret }); await app.register(cors, { - origin: true, + origin: process.env.NODE_ENV === "production" + ? config.WEBAUTHN_ORIGIN + : ["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"], credentials: true, }); + + const allowedOrigins = new Set( + process.env.NODE_ENV === "production" + ? [config.WEBAUTHN_ORIGIN] + : [config.WEBAUTHN_ORIGIN, "http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"] + ); + app.addHook("onRequest", async (req, reply) => { + if (["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) { + const origin = req.headers.origin; + // Server-to-server webhook calls don't send Origin headers + if (!origin && req.url.startsWith('/api/v1/plugins/') && req.url.includes('/webhook')) return; + if (!origin || !allowedOrigins.has(origin)) { + return reply.status(403).send({ error: "Forbidden" }); + } + } + }); + + await app.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } }); await app.register(rateLimit, { max: 100, timeWindow: "1 minute", }); + app.setErrorHandler((error: FastifyError, req, reply) => { + req.log.error(error); + + // zod validation errors + if (error.validation) { + reply.status(400).send({ error: "Validation failed" }); + return; + } + + // fastify rate limit + if (error.statusCode === 429) { + reply.status(429).send({ error: "Too many requests" }); + return; + } + + const status = error.statusCode ?? 500; + reply.status(status).send({ + error: status >= 500 ? "Internal server error" : error.message, + }); + }); + await app.register(securityPlugin); await app.register(authPlugin); + app.decorate("prisma", prisma); + // api routes under /api/v1 await app.register(async (api) => { await api.register(boardRoutes); @@ -72,6 +141,25 @@ export async function createServer() { await api.register(adminBoardRoutes); await api.register(adminCategoryRoutes); await api.register(adminStatsRoutes); + await api.register(searchRoutes); + await api.register(roadmapRoutes); + await api.register(similarRoutes); + await api.register(adminTagRoutes); + await api.register(adminNoteRoutes); + await api.register(adminChangelogRoutes); + await api.register(adminWebhookRoutes); + await api.register(changelogRoutes); + await api.register(notificationRoutes); + await api.register(embedRoutes); + await api.register(adminStatusRoutes); + await api.register(adminExportRoutes); + await api.register(adminTemplateRoutes); + await api.register(templateRoutes); + await api.register(attachmentRoutes); + await api.register(avatarRoutes); + await api.register(recoveryRoutes); + await api.register(settingsRoutes); + await api.register(adminTeamRoutes); }, { prefix: "/api/v1" }); // serve static frontend build in production @@ -88,6 +176,29 @@ export async function createServer() { } await loadPlugins(app); + await startupPlugins(); + + // seed default templates for boards that have none + await seedAllBoardTemplates(prisma); + + // register plugin discovery endpoint and admin routes + await app.register(async (api) => { + api.get("/plugins/active", { + config: { rateLimit: { max: 30, timeWindow: "1 minute" } }, + }, async () => getActivePluginInfo()); + + // register plugin-provided admin routes + for (const route of getPluginAdminRoutes()) { + api.get(`/plugins${route.path}`, { preHandler: [app.requireAdmin] }, async () => ({ + label: route.label, + component: route.component, + })); + } + }, { prefix: "/api/v1" }); + + app.addHook("onClose", async () => { + await shutdownPlugins(); + }); return app; } diff --git a/packages/api/src/services/altcha.ts b/packages/api/src/services/altcha.ts index 7bcd046..9717340 100644 --- a/packages/api/src/services/altcha.ts +++ b/packages/api/src/services/altcha.ts @@ -1,6 +1,23 @@ import { createChallenge, verifySolution } from "altcha-lib"; +import { createHash } from "node:crypto"; import { config } from "../config.js"; +// replay protection: track consumed challenge hashes (fingerprint -> timestamp) +const usedChallenges = new Map(); +const EXPIRY_MS = 300000; + +// clean up expired entries every 5 minutes +setInterval(() => { + const cutoff = Date.now() - EXPIRY_MS; + for (const [fp, ts] of usedChallenges) { + if (ts < cutoff) usedChallenges.delete(fp); + } +}, EXPIRY_MS); + +function challengeFingerprint(payload: string): string { + return createHash("sha256").update(payload).digest("hex").slice(0, 32); +} + export async function generateChallenge(difficulty: "normal" | "light" = "normal") { const maxNumber = difficulty === "light" ? config.ALTCHA_MAX_NUMBER_VOTE : config.ALTCHA_MAX_NUMBER; const challenge = await createChallenge({ @@ -13,8 +30,30 @@ export async function generateChallenge(difficulty: "normal" | "light" = "normal export async function verifyChallenge(payload: string): Promise { try { + const fp = challengeFingerprint(payload); + + // reject replayed challenges + if (usedChallenges.has(fp)) return false; + const ok = await verifySolution(payload, config.ALTCHA_HMAC_KEY); - return ok; + if (!ok) return false; + + // evict expired entries if map is large + if (usedChallenges.size >= 50000) { + const cutoff = Date.now() - EXPIRY_MS; + for (const [key, ts] of usedChallenges) { + if (ts < cutoff) usedChallenges.delete(key); + } + } + // hard cap - drop oldest entries if still over limit + if (usedChallenges.size >= 50000) { + const sorted = [...usedChallenges.entries()].sort((a, b) => a[1] - b[1]); + const toRemove = sorted.slice(0, sorted.length - 40000); + for (const [key] of toRemove) usedChallenges.delete(key); + } + usedChallenges.set(fp, Date.now()); + + return true; } catch { return false; } diff --git a/packages/api/src/services/encryption.ts b/packages/api/src/services/encryption.ts index e7a751f..aa2c80b 100644 --- a/packages/api/src/services/encryption.ts +++ b/packages/api/src/services/encryption.ts @@ -21,6 +21,15 @@ export function decrypt(encoded: string, key: Buffer): string { return decipher.update(ciphertext) + decipher.final("utf8"); } +export function decryptWithFallback(encoded: string, currentKey: Buffer, previousKey: Buffer | null): string { + try { + return decrypt(encoded, currentKey); + } catch { + if (previousKey) return decrypt(encoded, previousKey); + throw new Error("Decryption failed with all available keys"); + } +} + export function blindIndex(value: string, key: Buffer): string { return createHmac("sha256", key).update(value.toLowerCase()).digest("hex"); } diff --git a/packages/api/src/services/key-rotation.ts b/packages/api/src/services/key-rotation.ts new file mode 100644 index 0000000..96fa546 --- /dev/null +++ b/packages/api/src/services/key-rotation.ts @@ -0,0 +1,145 @@ +import prisma from "../lib/prisma.js"; +import { encrypt, decryptWithFallback } from "./encryption.js"; +import { masterKey, previousMasterKey } from "../config.js"; + +export async function reEncryptIfNeeded() { + if (!previousMasterKey) return; + + console.log("Key rotation: checking for records encrypted with previous key..."); + let reEncrypted = 0; + let failures = 0; + + // re-encrypt user fields + const users = await prisma.user.findMany({ + where: { OR: [{ displayName: { not: null } }, { username: { not: null } }] }, + select: { id: true, displayName: true, username: true }, + }); + + for (const user of users) { + const updates: Record = {}; + let needsUpdate = false; + + if (user.displayName) { + try { + const plain = decryptWithFallback(user.displayName, masterKey, previousMasterKey); + const reEnc = encrypt(plain, masterKey); + if (reEnc !== user.displayName) { + updates.displayName = reEnc; + needsUpdate = true; + } + } catch { failures++; } + } + + if (user.username) { + try { + const plain = decryptWithFallback(user.username, masterKey, previousMasterKey); + const reEnc = encrypt(plain, masterKey); + if (reEnc !== user.username) { + updates.username = reEnc; + needsUpdate = true; + } + } catch { failures++; } + } + + if (needsUpdate) { + await prisma.user.update({ where: { id: user.id }, data: updates }); + reEncrypted++; + } + } + + // re-encrypt passkey fields + const passkeys = await prisma.passkey.findMany({ + select: { id: true, credentialId: true, credentialPublicKey: true, transports: true }, + }); + + for (const pk of passkeys) { + const updates: Record = {}; + let needsUpdate = false; + + for (const field of ["credentialId", "transports"] as const) { + const val = pk[field] as string; + if (val) { + try { + const plain = decryptWithFallback(val, masterKey, previousMasterKey); + const reEnc = encrypt(plain, masterKey); + if (reEnc !== val) { + updates[field] = reEnc; + needsUpdate = true; + } + } catch { failures++; } + } + } + + // re-encrypt public key (stored as encrypted string in Bytes field) + if (pk.credentialPublicKey) { + try { + const pubKeyStr = pk.credentialPublicKey.toString(); + const plain = decryptWithFallback(pubKeyStr, masterKey, previousMasterKey); + const reEnc = encrypt(plain, masterKey); + if (reEnc !== pubKeyStr) { + updates.credentialPublicKey = Buffer.from(reEnc); + needsUpdate = true; + } + } catch { failures++; } + } + + if (needsUpdate) { + await prisma.passkey.update({ where: { id: pk.id }, data: updates }); + reEncrypted++; + } + } + + // re-encrypt push subscription fields + const pushSubs = await prisma.pushSubscription.findMany({ + select: { id: true, endpoint: true, keysP256dh: true, keysAuth: true }, + }); + + for (const sub of pushSubs) { + const updates: Record = {}; + let needsUpdate = false; + + for (const field of ["endpoint", "keysP256dh", "keysAuth"] as const) { + const val = sub[field]; + if (val) { + try { + const plain = decryptWithFallback(val, masterKey, previousMasterKey); + const reEnc = encrypt(plain, masterKey); + if (reEnc !== val) { + updates[field] = reEnc; + needsUpdate = true; + } + } catch { failures++; } + } + } + + if (needsUpdate) { + await prisma.pushSubscription.update({ where: { id: sub.id }, data: updates }); + reEncrypted++; + } + } + + // re-encrypt webhook secrets + const webhooks = await prisma.webhook.findMany({ + select: { id: true, secret: true }, + }); + + for (const wh of webhooks) { + try { + const plain = decryptWithFallback(wh.secret, masterKey, previousMasterKey); + const reEnc = encrypt(plain, masterKey); + if (reEnc !== wh.secret) { + await prisma.webhook.update({ where: { id: wh.id }, data: { secret: reEnc } }); + reEncrypted++; + } + } catch {} + } + + if (failures > 0) { + console.warn(`Key rotation: ${failures} records failed to re-encrypt`); + } + if (reEncrypted > 0) { + console.log(`Key rotation: re-encrypted ${reEncrypted} records`); + } else { + console.log("Key rotation: all records already use current key"); + } +} diff --git a/packages/api/src/services/manifest-validator.ts b/packages/api/src/services/manifest-validator.ts new file mode 100644 index 0000000..dcbcbbf --- /dev/null +++ b/packages/api/src/services/manifest-validator.ts @@ -0,0 +1,40 @@ +import { Prisma } from "@prisma/client"; + +const TRACKED_MODELS = ["User", "Passkey", "PushSubscription"] as const; + +const MANIFEST_FIELDS: Record = { + User: [ + "id", "authMethod", "tokenHash", "username", "usernameIdx", + "displayName", "avatarPath", "darkMode", "createdAt", "updatedAt", + "posts", "comments", "votes", "reactions", "passkeys", "notifications", "pushSubscriptions", "adminLink", "attachments", "edits", "recoveryCode", + ], + Passkey: [ + "id", "credentialId", "credentialIdIdx", "credentialPublicKey", + "counter", "credentialDeviceType", "credentialBackedUp", "transports", + "userId", "user", "createdAt", + ], + PushSubscription: [ + "id", "endpoint", "endpointIdx", "keysP256dh", "keysAuth", + "userId", "user", "boardId", "board", "postId", "post", "failureCount", "createdAt", + ], +}; + +export function validateManifest() { + for (const modelName of TRACKED_MODELS) { + const dmmfModel = Prisma.dmmf.datamodel.models.find((m) => m.name === modelName); + if (!dmmfModel) continue; + + const schemaFields = dmmfModel.fields.map((f) => f.name); + const manifestFields = MANIFEST_FIELDS[modelName] || []; + + for (const field of schemaFields) { + if (!manifestFields.includes(field)) { + console.error( + `Data manifest violation: field "${modelName}.${field}" exists in schema but is not declared in the data manifest. ` + + `Add it to the manifest or the app will not start.` + ); + process.exit(1); + } + } + } +} diff --git a/packages/api/src/services/push.ts b/packages/api/src/services/push.ts index 7821283..4a8e447 100644 --- a/packages/api/src/services/push.ts +++ b/packages/api/src/services/push.ts @@ -1,11 +1,9 @@ import webpush from "web-push"; -import { PrismaClient } from "@prisma/client"; +import prisma from "../lib/prisma.js"; import { config } from "../config.js"; import { decrypt } from "./encryption.js"; import { masterKey } from "../config.js"; -const prisma = new PrismaClient(); - if (config.VAPID_PUBLIC_KEY && config.VAPID_PRIVATE_KEY && config.VAPID_CONTACT) { webpush.setVapidDetails( config.VAPID_CONTACT, @@ -21,7 +19,7 @@ interface PushPayload { tag?: string; } -export async function sendNotification(sub: { endpoint: string; keysP256dh: string; keysAuth: string }, payload: PushPayload) { +export async function sendNotification(sub: { id: string; endpoint: string; keysP256dh: string; keysAuth: string }, payload: PushPayload): Promise<"ok" | "gone" | "failed"> { try { await webpush.sendNotification( { @@ -33,39 +31,58 @@ export async function sendNotification(sub: { endpoint: string; keysP256dh: stri }, JSON.stringify(payload) ); - return true; + return "ok"; } catch (err: any) { if (err.statusCode === 404 || err.statusCode === 410) { - return false; + return "gone"; } - throw err; + // transient failure - increment counter + await prisma.pushSubscription.update({ + where: { id: sub.id }, + data: { failureCount: { increment: 1 } }, + }).catch(() => {}); + return "failed"; + } +} + +const MAX_FANOUT = 5000; +const BATCH_SIZE = 50; + +async function processResults(subs: { id: string; endpoint: string; keysP256dh: string; keysAuth: string }[], event: PushPayload) { + if (subs.length > MAX_FANOUT) { + console.warn(`push fanout capped: ${subs.length} subscribers truncated to ${MAX_FANOUT}`); + } + const capped = subs.slice(0, MAX_FANOUT); + const gone: string[] = []; + + for (let i = 0; i < capped.length; i += BATCH_SIZE) { + const batch = capped.slice(i, i + BATCH_SIZE); + const results = await Promise.allSettled( + batch.map((sub) => sendNotification(sub, event).then((r) => ({ id: sub.id, result: r }))) + ); + for (const r of results) { + if (r.status === "fulfilled" && r.value.result === "gone") { + gone.push(r.value.id); + } + } + } + + if (gone.length > 0) { + await prisma.pushSubscription.deleteMany({ where: { id: { in: gone } } }); } } export async function notifyPostSubscribers(postId: string, event: PushPayload) { const subs = await prisma.pushSubscription.findMany({ where: { postId } }); - const failed: string[] = []; - - for (const sub of subs) { - const ok = await sendNotification(sub, event); - if (!ok) failed.push(sub.id); - } - - if (failed.length > 0) { - await prisma.pushSubscription.deleteMany({ where: { id: { in: failed } } }); - } + await processResults(subs, event); } export async function notifyBoardSubscribers(boardId: string, event: PushPayload) { const subs = await prisma.pushSubscription.findMany({ where: { boardId, postId: null } }); - const failed: string[] = []; - - for (const sub of subs) { - const ok = await sendNotification(sub, event); - if (!ok) failed.push(sub.id); - } - - if (failed.length > 0) { - await prisma.pushSubscription.deleteMany({ where: { id: { in: failed } } }); - } + await processResults(subs, event); +} + +export async function notifyUserReply(userId: string, event: PushPayload) { + const subs = await prisma.pushSubscription.findMany({ where: { userId } }); + await processResults(subs, event); } diff --git a/packages/api/src/services/webhooks.ts b/packages/api/src/services/webhooks.ts new file mode 100644 index 0000000..6d6b565 --- /dev/null +++ b/packages/api/src/services/webhooks.ts @@ -0,0 +1,104 @@ +import prisma from "../lib/prisma.js"; +import { createHmac } from "node:crypto"; +import { resolve as dnsResolve } from "node:dns/promises"; +import { request as httpsRequest } from "node:https"; +import { decrypt } from "./encryption.js"; +import { masterKey } from "../config.js"; + +function isPrivateIp(ip: string): boolean { + // normalize IPv6-mapped IPv4 (::ffff:127.0.0.1 -> 127.0.0.1) + const normalized = ip.startsWith("::ffff:") ? ip.slice(7) : ip; + if (normalized === "127.0.0.1" || normalized === "::1") return true; + if (normalized.startsWith("0.")) return true; // 0.0.0.0/8 + if (normalized.startsWith("10.") || normalized.startsWith("192.168.")) return true; + if (/^172\.(1[6-9]|2\d|3[01])\./.test(normalized)) return true; + if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(normalized)) return true; // 100.64.0.0/10 CGNAT + if (normalized.startsWith("198.18.") || normalized.startsWith("198.19.")) return true; // 198.18.0.0/15 + if (/^24[0-9]\./.test(normalized) || normalized.startsWith("255.")) return true; // 240.0.0.0/4 + if (normalized === "169.254.169.254" || normalized.startsWith("169.254.")) return true; // link-local + if (normalized.startsWith("fe80:") || normalized.startsWith("fc00:") || normalized.startsWith("fd")) return true; + return false; +} + +export function isAllowedUrl(raw: string): boolean { + try { + const u = new URL(raw); + if (u.protocol !== "https:") return false; + const host = u.hostname; + if (host === "localhost" || isPrivateIp(host)) return false; + if (host.endsWith(".internal") || host.endsWith(".local")) return false; + return true; + } catch { + return false; + } +} + +export async function resolvedIpIsAllowed(hostname: string): Promise { + try { + const addresses = await dnsResolve(hostname); + for (const addr of addresses) { + if (isPrivateIp(addr)) return false; + } + return true; + } catch { + return false; + } +} + +export async function fireWebhook(event: string, payload: Record) { + const webhooks = await prisma.webhook.findMany({ + where: { active: true, events: { has: event } }, + }); + + for (const wh of webhooks) { + if (!isAllowedUrl(wh.url)) continue; + const url = new URL(wh.url); + + let addresses: string[]; + try { + addresses = await dnsResolve(url.hostname); + if (addresses.some((addr) => isPrivateIp(addr))) continue; + } catch { + continue; + } + + let secret: string; + try { + secret = decrypt(wh.secret, masterKey); + } catch (err: any) { + console.warn(`webhook ${wh.id}: secret decryption failed - ${err?.message ?? "unknown error"}`); + continue; + } + + const body = JSON.stringify({ event, timestamp: new Date().toISOString(), data: payload }); + const signature = createHmac("sha256", secret).update(body).digest("hex"); + + // connect directly to the resolved IP, use original hostname for TLS SNI + // and Host header - this closes the DNS rebinding window + const req = httpsRequest({ + hostname: addresses[0], + port: url.port || 443, + path: url.pathname + url.search, + method: "POST", + headers: { + "Host": url.hostname, + "Content-Type": "application/json", + "X-Echoboard-Signature": signature, + "X-Echoboard-Event": event, + }, + servername: url.hostname, + timeout: 10000, + }, (res) => { + res.resume(); // drain response + }); + + req.setTimeout(10000, () => { + req.destroy(new Error("timeout")); + }); + req.on("error", (err) => { + console.warn(`webhook delivery failed for ${wh.id}: ${err.message}`); + }); + req.write(body); + req.end(); + } +} diff --git a/packages/web/package.json b/packages/web/package.json index ee02a63..aa72fc1 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -9,15 +9,21 @@ "preview": "vite preview" }, "dependencies": { - "@fontsource/space-grotesk": "^5.0.0", "@fontsource/sora": "^5.0.0", + "@fontsource/space-grotesk": "^5.0.0", "@simplewebauthn/browser": "^11.0.0", + "@tabler/icons-react": "^3.40.0", + "dompurify": "^3.3.3", "react": "^18.3.0", "react-dom": "^18.3.0", - "react-router-dom": "^7.0.0" + "react-markdown": "^10.1.0", + "react-router-dom": "^7.0.0", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@types/dompurify": "^3.0.5", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.0", diff --git a/packages/web/public/embed.js b/packages/web/public/embed.js new file mode 100644 index 0000000..044965a --- /dev/null +++ b/packages/web/public/embed.js @@ -0,0 +1,92 @@ +(function() { + 'use strict'; + var scripts = document.querySelectorAll('script[data-echoboard]'); + for (var i = 0; i < scripts.length; i++) { + var s = scripts[i]; + if (s.getAttribute('data-rendered')) continue; + s.setAttribute('data-rendered', '1'); + + var board = s.getAttribute('data-board'); + var baseUrl = s.getAttribute('data-url') || s.src.replace(/\/embed\.js.*$/, ''); + var theme = s.getAttribute('data-theme') || 'dark'; + var limit = s.getAttribute('data-limit') || '10'; + var sort = s.getAttribute('data-sort') || 'top'; + var height = parseInt(s.getAttribute('data-height') || '500', 10) || 500; + var mode = s.getAttribute('data-mode') || 'inline'; + var label = s.getAttribute('data-label') || 'Feedback'; + var position = s.getAttribute('data-position') || 'right'; + + if (!board) continue; + + var params = '?theme=' + encodeURIComponent(theme) + + '&limit=' + encodeURIComponent(limit) + + '&sort=' + encodeURIComponent(sort); + var src = baseUrl + '/embed/' + encodeURIComponent(board) + params; + + if (mode === 'button') { + // Floating feedback button mode + var isRight = position !== 'left'; + var isDark = theme === 'dark'; + var accent = '#F59E0B'; + + // Button + var btn = document.createElement('button'); + btn.textContent = label; + btn.style.cssText = 'position:fixed;bottom:24px;' + (isRight ? 'right' : 'left') + ':24px;' + + 'z-index:999998;padding:10px 20px;border:none;border-radius:24px;cursor:pointer;' + + 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;font-weight:600;' + + 'background:' + accent + ';color:#161616;box-shadow:0 4px 16px rgba(0,0,0,0.3);' + + 'transition:transform 0.2s ease,box-shadow 0.2s ease;'; + btn.onmouseenter = function() { btn.style.transform = 'scale(1.05)'; btn.style.boxShadow = '0 6px 24px rgba(0,0,0,0.4)'; }; + btn.onmouseleave = function() { btn.style.transform = 'scale(1)'; btn.style.boxShadow = '0 4px 16px rgba(0,0,0,0.3)'; }; + + // Popup container + var popup = document.createElement('div'); + popup.style.cssText = 'position:fixed;bottom:80px;' + (isRight ? 'right' : 'left') + ':24px;' + + 'z-index:999999;width:380px;max-width:calc(100vw - 48px);height:' + height + 'px;max-height:calc(100vh - 120px);' + + 'border-radius:12px;overflow:hidden;' + + 'box-shadow:0 8px 40px rgba(0,0,0,0.4);border:1px solid ' + (isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)') + ';' + + 'display:none;transform:translateY(12px);opacity:0;transition:transform 0.25s ease,opacity 0.25s ease;'; + + var popupIframe = document.createElement('iframe'); + popupIframe.style.cssText = 'width:100%;height:100%;border:none;'; + popupIframe.setAttribute('title', 'Echoboard - ' + board); + popupIframe.setAttribute('sandbox', 'allow-scripts'); + popup.appendChild(popupIframe); + + var open = false; + btn.onclick = function() { + if (!open) { + if (!popupIframe.src) popupIframe.src = src; + popup.style.display = 'block'; + // force reflow + popup.offsetHeight; + popup.style.transform = 'translateY(0)'; + popup.style.opacity = '1'; + open = true; + } else { + popup.style.transform = 'translateY(12px)'; + popup.style.opacity = '0'; + setTimeout(function() { popup.style.display = 'none'; }, 250); + open = false; + } + }; + + document.body.appendChild(btn); + document.body.appendChild(popup); + } else { + // Inline embed mode (default) + var iframe = document.createElement('iframe'); + iframe.src = src; + iframe.style.width = '100%'; + iframe.style.height = height + 'px'; + iframe.style.border = 'none'; + iframe.style.borderRadius = '8px'; + iframe.style.overflow = 'hidden'; + iframe.setAttribute('loading', 'lazy'); + iframe.setAttribute('title', 'Echoboard - ' + board); + iframe.setAttribute('sandbox', 'allow-scripts'); + s.parentNode.insertBefore(iframe, s.nextSibling); + } + } +})(); diff --git a/packages/web/public/sw.js b/packages/web/public/sw.js new file mode 100644 index 0000000..2fabfe7 --- /dev/null +++ b/packages/web/public/sw.js @@ -0,0 +1,22 @@ +self.addEventListener("push", (event) => { + let data = { title: "Echoboard", body: "New activity on your watched content" }; + try { + data = event.data.json(); + } catch {} + + event.waitUntil( + self.registration.showNotification(data.title, { + body: data.body, + icon: data.icon || "/favicon.ico", + data: { url: data.url }, + }) + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const raw = event.notification.data?.url || "/"; + const safe = new URL(raw, self.location.origin); + const url = safe.origin === self.location.origin ? safe.href : "/"; + event.waitUntil(clients.openWindow(url)); +}); diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index b63b362..3f2d943 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,8 +1,14 @@ -import { useState } from 'react' -import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom' +import { useState, useEffect, useRef } from 'react' +import { BrowserRouter, Routes, Route, useLocation, useNavigate, Link } from 'react-router-dom' +import { IconShieldCheck, IconArrowRight, IconX, IconSearch, IconChevronUp, IconMessageCircle, IconBug, IconBulb, IconCommand, IconArrowNarrowRight } from '@tabler/icons-react' import { AuthProvider, useAuthState } from './hooks/useAuth' +import { AdminProvider, useAdminState, useAdmin } from './hooks/useAdmin' import { ThemeProvider, useThemeState } from './hooks/useTheme' +import { TranslationProvider, useTranslationState } from './i18n' +import { BrandingProvider } from './hooks/useBranding' +import { ConfirmProvider } from './hooks/useConfirm' import Sidebar from './components/Sidebar' +import AdminSidebar from './components/AdminSidebar' import MobileNav from './components/MobileNav' import ThemeToggle from './components/ThemeToggle' import IdentityBanner from './components/IdentityBanner' @@ -19,37 +25,495 @@ import AdminLogin from './pages/admin/AdminLogin' import AdminDashboard from './pages/admin/AdminDashboard' import AdminPosts from './pages/admin/AdminPosts' import AdminBoards from './pages/admin/AdminBoards' +import AdminCategories from './pages/admin/AdminCategories' +import AdminDataRetention from './pages/admin/AdminDataRetention' +import AdminTags from './pages/admin/AdminTags' +import RoadmapPage from './pages/RoadmapPage' +import ChangelogPage from './pages/ChangelogPage' +import AdminChangelog from './pages/admin/AdminChangelog' +import AdminWebhooks from './pages/admin/AdminWebhooks' +import AdminEmbed from './pages/admin/AdminEmbed' +import AdminStatuses from './pages/admin/AdminStatuses' +import AdminExport from './pages/admin/AdminExport' +import AdminTemplates from './pages/admin/AdminTemplates' +import AdminSettings from './pages/admin/AdminSettings' +import AdminTeam from './pages/admin/AdminTeam' +import AdminJoin from './pages/admin/AdminJoin' +import ProfilePage from './pages/ProfilePage' +import RecoverPage from './pages/RecoverPage' +import EmbedBoard from './pages/EmbedBoard' +import { api } from './lib/api' +import BoardIcon from './components/BoardIcon' +import StatusBadge from './components/StatusBadge' +import Avatar from './components/Avatar' + +interface SearchBoard { + type: 'board' + id: string + title: string + slug: string + iconName: string | null + iconColor: string | null + description: string | null + postCount: number +} + +interface SearchPost { + type: 'post' + id: string + title: string + postType: 'FEATURE_REQUEST' | 'BUG_REPORT' + status: string + voteCount: number + commentCount: number + boardSlug: string + boardName: string + boardIconName: string | null + boardIconColor: string | null + author: { id: string; displayName: string | null; avatarUrl: string | null } | null + createdAt: string +} + +function searchTimeAgo(date: string): string { + const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000) + if (seconds < 60) return 'just now' + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + if (days < 30) return `${days}d ago` + return `${Math.floor(days / 30)}mo ago` +} + +function SearchPage() { + const [query, setQuery] = useState('') + const [boards, setBoards] = useState([]) + const [posts, setPosts] = useState([]) + const [loading, setLoading] = useState(false) + const [searched, setSearched] = useState(false) + + useEffect(() => { + if (!query.trim()) { setBoards([]); setPosts([]); setSearched(false); return } + const t = setTimeout(async () => { + setLoading(true) + try { + const res = await api.get<{ boards: SearchBoard[]; posts: SearchPost[] }>(`/search?q=${encodeURIComponent(query)}`) + setBoards(res.boards) + setPosts(res.posts) + } catch { setBoards([]); setPosts([]) } + setSearched(true) + setLoading(false) + }, 200) + return () => clearTimeout(t) + }, [query]) + + const totalResults = boards.length + posts.length + const isMac = navigator.platform?.includes('Mac') + + return ( +
+

Search

+ {/* Search input */} +
+ + setQuery(e.target.value)} + autoFocus + aria-label="Search posts, feedback, and boards" + /> +
+ + {/* Status line */} +
+ {loading &&

Searching...

} + {!loading && searched && ( +

+ {totalResults === 0 ? 'No results found - try different keywords or check for typos' : `${totalResults} result${totalResults === 1 ? '' : 's'} found`} +

+ )} +
+ + {/* Empty state - before searching */} + {!query.trim() && !searched && ( +
+
+ +
+

+ Search across all boards +

+

+ Find posts, bug reports, feature requests, and boards. Typos are forgiven - the search is fuzzy. +

+
+ + {isMac ? : 'Ctrl+'}K + + for quick search anywhere +
+
+ )} + + {/* No results state */} + {!loading && searched && totalResults === 0 && ( +
+
+ +
+

+ Nothing matched "{query}" +

+

+ Try broader terms or check the spelling +

+
+ )} + + {/* Board results */} + {boards.length > 0 && ( +
+

+ Boards +

+
+ {boards.map((b, i) => ( + + +
+
+ + {b.title} + + + {b.postCount} post{b.postCount !== 1 ? 's' : ''} + +
+ {b.description && ( +

+ {b.description} +

+ )} +
+ + + ))} +
+
+ )} + + {/* Post results */} + {posts.length > 0 && ( +
+

+ Posts +

+
+ {posts.map((p, i) => ( + +
+ {/* Vote count pill */} +
+ + + {p.voteCount} + +
+ + {/* Content */} +
+
+ + {p.postType === 'BUG_REPORT' + ? + +
+ +

+ {p.title} +

+ +
+ {/* Author */} + + + {p.author?.displayName ?? `Anonymous #${(p.author?.id ?? '0000').slice(-4)}`} + + + {/* Board */} + + + {p.boardName} + + + {/* Comments */} + + + {p.commentCount} + + + {/* Time */} + +
+
+
+ + ))} +
+
+ )} +
+ ) +} + +function RequireAdmin({ children }: { children: React.ReactNode }) { + const [ok, setOk] = useState(null) + const nav = useNavigate() + + useEffect(() => { + api.get('/admin/boards') + .then(() => setOk(true)) + .catch(() => nav('/admin/login', { replace: true })) + }, [nav]) + + if (!ok) return null + return <>{children} +} + +function NewPostRedirect() { + const nav = useNavigate() + useEffect(() => { nav('/') }, [nav]) + return null +} + +function AdminBanner() { + const admin = useAdmin() + const location = useLocation() + const isAdminPage = location.pathname.startsWith('/admin') + + if (!admin.isAdmin || isAdminPage) return null + + return ( +
+ + Admin mode +
+ { e.currentTarget.style.background = 'rgba(6, 182, 212, 0.2)' }} + onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(6, 182, 212, 0.1)' }} + onFocus={(e) => { e.currentTarget.style.background = 'rgba(6, 182, 212, 0.2)' }} + onBlur={(e) => { e.currentTarget.style.background = 'rgba(6, 182, 212, 0.1)' }} + > + Admin panel + + +
+
+ ) +} + +function RouteAnnouncer() { + const location = useLocation() + const [title, setTitle] = useState('') + const timerRef = useRef>() + + useEffect(() => { + clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => { + setTitle(document.title || location.pathname) + }, 300) + return () => clearTimeout(timerRef.current) + }, [location.pathname]) + + return ( +
+ {title} +
+ ) +} function Layout() { const location = useLocation() + const admin = useAdmin() const [passkeyMode, setPasskeyMode] = useState<'register' | 'login' | null>(null) - const isAdmin = location.pathname.startsWith('/admin') + const isAdminPage = location.pathname.startsWith('/admin') + const showAdminMode = admin.isAdmin && !isAdminPage return ( <> + Skip to main content + -
- {!isAdmin && } -
+
+ {isAdminPage ? : } +
+ } /> } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> + } /> } /> + } /> + } /> + } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } />
- {!isAdmin && } + {!isAdminPage && } - {!isAdmin && ( + {!isAdminPage && ( setPasskeyMode('register')} /> )} { + if (auth.user?.isPasskeyUser) { + api.put('/me', { darkMode: t }).catch(() => {}) + } + }) + + useEffect(() => { + if (auth.user?.isPasskeyUser && auth.user.darkMode) { + theme.set(auth.user.darkMode as 'dark' | 'light' | 'system') + } + }, [auth.user?.isPasskeyUser]) return ( - - - - - - - + + + + + + + + + } /> + } /> + + + + + + + + ) } diff --git a/packages/web/src/app.css b/packages/web/src/app.css index 0565746..2156d98 100644 --- a/packages/web/src/app.css +++ b/packages/web/src/app.css @@ -2,41 +2,154 @@ @layer base { :root { - --bg: #141420; - --surface: #1c1c2e; - --surface-hover: #24243a; - --border: rgba(245, 240, 235, 0.08); - --border-hover: rgba(245, 240, 235, 0.15); - --text: #f5f0eb; - --text-secondary: rgba(245, 240, 235, 0.6); - --text-tertiary: rgba(245, 240, 235, 0.35); + /* Neutral dark palette */ + --bg: #161616; + --surface: #1e1e1e; + --surface-hover: #272727; + --surface-raised: #2a2a2a; + --border: rgba(255, 255, 255, 0.08); + --border-hover: rgba(255, 255, 255, 0.15); + --border-accent: rgba(245, 158, 11, 0.3); + + /* Text */ + --text: #f0f0f0; + --text-secondary: rgba(240, 240, 240, 0.72); + --text-tertiary: rgba(240, 240, 240, 0.71); + + /* Accent - amber/gold */ --accent: #F59E0B; - --accent-hover: #D97706; - --accent-subtle: rgba(245, 158, 11, 0.15); - --admin-accent: #06B6D4; - --admin-subtle: rgba(6, 182, 212, 0.15); + --accent-hover: #FBBF24; + --accent-dim: #D97706; + --accent-subtle: rgba(245, 158, 11, 0.12); + --accent-glow: rgba(245, 158, 11, 0.25); + + /* Admin - cyan */ + --admin-accent: #08C4E4; + --admin-subtle: rgba(8, 196, 228, 0.12); + + /* Semantic */ --success: #22C55E; --warning: #EAB308; - --error: #EF4444; - --info: #3B82F6; + --error: #F98A8A; + --info: #6DB5FC; + + /* Status badge colors (bright for dark bg) */ + --status-open: #F59E0B; + --status-review: #22D3EE; + --status-planned: #6DB5FC; + --status-progress: #EAB308; + --status-done: #22C55E; + --status-declined: #F98A8A; + + /* Typography */ --font-heading: 'Space Grotesk', system-ui, sans-serif; --font-body: 'Sora', system-ui, sans-serif; + + /* Type scale - Perfect Fourth (1.333) */ + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.313rem; + --text-xl: 1.75rem; + --text-2xl: 2.375rem; + --text-3xl: 3.125rem; + + /* Spacing scale */ + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 32px; + --space-2xl: 48px; + --space-3xl: 64px; + + /* Radii - soft friendly */ + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-full: 9999px; + + /* Shadows - soft layered */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 2px rgba(0, 0, 0, 0.15); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 2px 4px rgba(0, 0, 0, 0.15); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.3), 0 4px 8px rgba(0, 0, 0, 0.15); + --shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.35), 0 8px 16px rgba(0, 0, 0, 0.2); + --shadow-glow: 0 0 20px rgba(245, 158, 11, 0.15); + + /* Transitions */ + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --duration-fast: 150ms; + --duration-normal: 250ms; + --duration-slow: 400ms; + + /* Layout */ + --sidebar-expanded: 300px; + --sidebar-collapsed: 64px; + --content-max: 920px; } html.light { - --bg: #faf9f6; + --bg: #f7f8fa; --surface: #ffffff; - --surface-hover: #f0eeea; - --border: rgba(20, 20, 32, 0.08); - --border-hover: rgba(20, 20, 32, 0.15); - --text: #1a1a2e; - --text-secondary: rgba(26, 26, 46, 0.6); - --text-tertiary: rgba(26, 26, 46, 0.35); - --accent: #D97706; - --accent-hover: #B45309; - --accent-subtle: rgba(217, 119, 6, 0.15); - --admin-accent: #0891B2; - --admin-subtle: rgba(8, 145, 178, 0.15); + --surface-hover: #f0f1f3; + --surface-raised: #f5f6f8; + --border: rgba(0, 0, 0, 0.08); + --border-hover: rgba(0, 0, 0, 0.15); + --border-accent: rgba(112, 73, 9, 0.3); + + --text: #1a1a1a; + --text-secondary: #4a4a4a; + --text-tertiary: #545454; + + --accent: #704909; + --accent-hover: #855609; + --accent-dim: #5C3C06; + --accent-subtle: rgba(112, 73, 9, 0.1); + --accent-glow: rgba(112, 73, 9, 0.2); + + --admin-accent: #0A5C73; + --admin-subtle: rgba(10, 92, 115, 0.1); + + --success: #166534; + --warning: #74430C; + --error: #991919; + --info: #1A40B0; + + /* Status badge colors (darker for light bg) */ + --status-open: #704909; + --status-review: #0A5C73; + --status-planned: #1A40B0; + --status-progress: #74430C; + --status-done: #166534; + --status-declined: #991919; + + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1), 0 4px 8px rgba(0, 0, 0, 0.06); + --shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.12), 0 8px 16px rgba(0, 0, 0, 0.06); + --shadow-glow: 0 0 20px rgba(112, 73, 9, 0.1); + } + + /* Admin mode - override accent with cyan */ + .admin-mode { + --accent: #08C4E4; + --accent-hover: #22D3EE; + --accent-dim: #06A3BE; + --accent-subtle: rgba(8, 196, 228, 0.12); + --accent-glow: rgba(8, 196, 228, 0.25); + --border-accent: rgba(8, 196, 228, 0.3); + --shadow-glow: 0 0 20px rgba(8, 196, 228, 0.15); + } + html.light .admin-mode { + --accent: #0A5C73; + --accent-hover: #0C6D87; + --accent-dim: #084B5E; + --accent-subtle: rgba(10, 92, 115, 0.1); + --accent-glow: rgba(10, 92, 115, 0.2); + --border-accent: rgba(10, 92, 115, 0.3); + --shadow-glow: 0 0 20px rgba(10, 92, 115, 0.1); } * { @@ -45,42 +158,75 @@ box-sizing: border-box; } + html { + scroll-padding-top: 48px; + } + body { background: var(--bg); color: var(--text); font-family: var(--font-body); + font-size: var(--text-base); + line-height: 1.5; -webkit-font-smoothing: antialiased; - transition: background 200ms ease-out, color 200ms ease-out; + transition: background var(--duration-normal) ease-out, color var(--duration-normal) ease-out; } h1, h2, h3, h4, h5, h6 { font-family: var(--font-heading); + line-height: 1.3; + } + + /* Focus ring - visible on all interactive elements */ + :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius-sm); } } @layer components { + /* ---------- Buttons ---------- */ .btn { + position: relative; display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; - padding: 0.5rem 1rem; - border-radius: 0.5rem; + padding: 0.75rem 1.25rem; + min-height: 44px; + border-radius: var(--radius-md); font-family: var(--font-body); font-weight: 500; - font-size: 0.875rem; - transition: all 200ms ease-out; + font-size: var(--text-sm); + transition: all var(--duration-normal) var(--ease-out); cursor: pointer; border: none; outline: none; + overflow: hidden; + } + .btn::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent); + transform: translateX(-100%); + transition: transform var(--duration-slow) ease-out; + } + .btn:hover::before { + transform: translateX(100%); + } + .btn:active { + transform: scale(0.97); } .btn-primary { background: var(--accent); - color: #141420; + color: #161616; + box-shadow: var(--shadow-sm); } .btn-primary:hover { - background: var(--accent-hover); + box-shadow: var(--shadow-glow), var(--shadow-md); } .btn-secondary { @@ -92,6 +238,9 @@ background: var(--surface-hover); border-color: var(--border-hover); } + .btn-secondary::before { + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.05), transparent); + } .btn-ghost { background: transparent; @@ -101,49 +250,296 @@ background: var(--surface-hover); color: var(--text); } + .btn-ghost::before { display: none; } + + html.light .btn-primary { color: #fff; } + html.light .btn-admin { color: #fff; } + + .icon-picker-btn { + transition: background var(--duration-fast) ease-out, color var(--duration-fast) ease-out; + } + .icon-picker-btn:hover { + background: var(--surface-hover) !important; + color: var(--text) !important; + } + + .number-input-btn { + transition: background var(--duration-fast) ease-out, color var(--duration-fast) ease-out; + } + .number-input-btn:not(:disabled):hover { + background: var(--surface-hover) !important; + color: var(--text) !important; + } + .number-input-btn:not(:disabled):active { + background: var(--border) !important; + } + + /* Small inline action buttons (edit, delete, lock, etc.) */ + .action-btn { + cursor: pointer; + border: none; + background: transparent; + transition: background var(--duration-fast) ease-out, color var(--duration-fast) ease-out, opacity var(--duration-fast) ease-out; + } + .action-btn:hover { + background: var(--surface-hover) !important; + opacity: 0.85; + filter: brightness(1.15); + } + .action-btn:active { + background: var(--border) !important; + } + + /* Sidebar/nav links with hover feedback */ + .nav-link { + transition: background var(--duration-fast) ease-out, color var(--duration-fast) ease-out; + } + .nav-link:hover { + background: var(--surface-hover) !important; + color: var(--text) !important; + } + + .roadmap-card:hover { + border-color: var(--border-hover) !important; + box-shadow: var(--shadow-md) !important; + } .btn-admin { background: var(--admin-accent); - color: #141420; + color: #161616; + box-shadow: var(--shadow-sm); } .btn-admin:hover { - opacity: 0.9; + box-shadow: var(--shadow-glow), var(--shadow-md); } + /* ---------- Cards ---------- */ .card { background: var(--surface); border: 1px solid var(--border); - border-radius: 0.75rem; - transition: border-color 200ms ease-out, box-shadow 200ms ease-out; - } - .card:hover { - border-color: var(--border-hover); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + transition: border-color var(--duration-normal) ease-out, box-shadow var(--duration-normal) ease-out, border-left-color var(--duration-normal) ease-out; } + /* Interactive cards get accent line on hover */ + .card-interactive { + border-left: 3px solid transparent; + } + .card-interactive:hover { + border-color: var(--border-hover); + border-left-color: var(--accent); + box-shadow: var(--shadow-md); + } + + /* Static cards (forms, settings) have no hover effect */ + .card-static { + cursor: default; + } + .card-static:hover { + border-color: var(--border); + box-shadow: var(--shadow-sm); + transform: none; + } + + /* ---------- Inputs ---------- */ .input { width: 100%; - padding: 0.625rem 0.875rem; - background: var(--surface); + padding: 0.75rem 1rem; + background: var(--bg); border: 1px solid var(--border); - border-radius: 0.5rem; + border-radius: var(--radius-md); color: var(--text); font-family: var(--font-body); - font-size: 0.875rem; - transition: border-color 200ms ease-out; + font-size: var(--text-sm); + transition: border-color var(--duration-normal) ease-out, box-shadow var(--duration-normal) ease-out; outline: none; } .input:focus { border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-subtle); } .input::placeholder { color: var(--text-tertiary); } + /* ---------- Markdown body ---------- */ + .markdown-body { + color: var(--text-secondary); + font-size: var(--text-sm); + line-height: 1.7; + word-wrap: break-word; + } + .markdown-body p { margin-bottom: 1em; } + .markdown-body p:last-child { margin-bottom: 0; } + .markdown-body strong { color: var(--text); font-weight: 600; } + .markdown-body em { font-style: italic; } + .markdown-body del { text-decoration: line-through; opacity: 0.7; } + .markdown-body a { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; + transition: opacity var(--duration-fast) ease-out; + } + .markdown-body a:hover { opacity: 0.8; } + .markdown-body code { + background: var(--bg); + padding: 0.15em 0.4em; + border-radius: var(--radius-sm); + font-size: 0.88em; + font-family: var(--font-mono); + } + .markdown-body pre { + background: var(--bg); + padding: 12px 16px; + border-radius: var(--radius-md); + overflow-x: auto; + margin: 0.5em 0; + } + .markdown-body pre code { + background: none; + padding: 0; + font-size: 0.85em; + } + .markdown-body ul, .markdown-body ol { + padding-left: 1.5em; + margin: 0.4em 0; + } + .markdown-body ul { list-style: disc; } + .markdown-body ol { list-style: decimal; } + .markdown-body li { margin: 0.25em 0; } + .markdown-body blockquote { + border-left: 3px solid var(--border); + padding-left: 12px; + color: var(--text-tertiary); + margin: 0.5em 0; + } + .markdown-body h1, + .markdown-body h2, + .markdown-body h3 { + overflow-wrap: break-word; + word-break: break-word; + } + .markdown-body h1 { + font-size: 1.5em; + font-weight: 700; + color: var(--text); + margin: 0.8em 0 0.4em; + line-height: 1.3; + } + .markdown-body h2 { + font-size: 1.25em; + font-weight: 600; + color: var(--text); + margin: 0.7em 0 0.3em; + line-height: 1.35; + } + .markdown-body h3 { + font-size: 1.1em; + font-weight: 600; + color: var(--text); + margin: 0.6em 0 0.25em; + line-height: 1.4; + } + .markdown-body h1:first-child, + .markdown-body h2:first-child, + .markdown-body h3:first-child { margin-top: 0; } + .markdown-body hr { + border: none; + border-top: 1px solid var(--border); + margin: 1em 0; + } + .markdown-body img { + max-width: 100%; + max-height: 400px; + object-fit: contain; + border-radius: var(--radius-md); + margin: 0.5em 0; + } + .markdown-body table { + width: 100%; + border-collapse: collapse; + margin: 0.5em 0; + font-size: 0.9em; + } + .markdown-body th, .markdown-body td { + border: 1px solid var(--border); + padding: 6px 10px; + text-align: left; + } + .markdown-body th { + background: var(--bg); + font-weight: 600; + color: var(--text); + } + .markdown-body input[type="checkbox"] { + margin-right: 6px; + accent-color: var(--accent); + pointer-events: none; + } + .markdown-body li:has(> input[type="checkbox"]) { + list-style: none; + margin-left: -1.5em; + } + + /* ---------- Skeleton loading ---------- */ + .skeleton { + background: linear-gradient(90deg, var(--surface) 25%, var(--surface-hover) 50%, var(--surface) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + border-radius: var(--radius-sm); + } + .skeleton-text { + height: 14px; + margin-bottom: 8px; + border-radius: 4px; + } + .skeleton-title { + height: 22px; + margin-bottom: 12px; + width: 60%; + border-radius: 4px; + } + .skeleton-card { + height: 100px; + border-radius: var(--radius-lg); + } + + /* ---------- Progress bar ---------- */ + .progress-bar { + position: relative; + height: 2px; + background: var(--border); + overflow: hidden; + border-radius: 1px; + } + .progress-bar::after { + content: ''; + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 30%; + background: var(--accent); + border-radius: 1px; + animation: progressSlide 1.2s ease-in-out infinite; + } + + /* ---------- Animations ---------- */ .slide-up { - animation: slideUp 300ms cubic-bezier(0.16, 1, 0.3, 1); + animation: slideUp 300ms var(--ease-out); } .fade-in { - animation: fadeIn 200ms ease-out; + animation: fadeIn var(--duration-normal) ease-out; + } + .slide-down { + animation: slideDown var(--duration-normal) ease-out; + } + + /* Staggered entrance - use with style="--stagger: N" */ + .stagger-in { + animation: staggerFadeIn var(--duration-slow) var(--ease-out) both; + animation-delay: calc(var(--stagger, 0) * 50ms); } @keyframes slideUp { @@ -158,4 +554,91 @@ from { transform: rotate(0deg); } to { transform: rotate(360deg); } } + @keyframes checkDraw { + from { stroke-dashoffset: 30; } + to { stroke-dashoffset: 0; } + } + @keyframes paletteIn { + from { transform: scale(0.97); opacity: 0; } + to { transform: scale(1); opacity: 1; } + } + @keyframes slideDown { + from { transform: translateY(-8px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } + } + @keyframes staggerFadeIn { + from { transform: translateY(12px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } + } + @keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } + } + @keyframes progressSlide { + 0% { left: -30%; } + 100% { left: 100%; } + } + @keyframes voteBounce { + 0% { transform: translateY(0); } + 30% { transform: translateY(-4px); } + 50% { transform: translateY(0); } + 70% { transform: translateY(-2px); } + 100% { transform: translateY(0); } + } + @keyframes countTick { + 0% { transform: scale(1); } + 50% { transform: scale(1.3); } + 100% { transform: scale(1); } + } + @keyframes successCheck { + from { stroke-dashoffset: 30; } + to { stroke-dashoffset: 0; } + } + + .vote-bounce { + animation: voteBounce 400ms var(--ease-spring); + } + .count-tick { + animation: countTick 300ms var(--ease-spring); + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} +.sr-only:focus { + position: fixed; + top: 8px; + left: 8px; + width: auto; + height: auto; + padding: 12px 24px; + margin: 0; + overflow: visible; + clip: auto; + white-space: normal; + z-index: 9999; + background: var(--surface); + color: var(--text); + border: 2px solid var(--accent); + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: 600; + box-shadow: var(--shadow-lg); +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } } diff --git a/packages/web/src/components/AdminSidebar.tsx b/packages/web/src/components/AdminSidebar.tsx new file mode 100644 index 0000000..7dbbd5a --- /dev/null +++ b/packages/web/src/components/AdminSidebar.tsx @@ -0,0 +1,144 @@ +import { useState, useEffect } from 'react' +import { Link, useLocation } from 'react-router-dom' +import { api } from '../lib/api' +import { useAdmin } from '../hooks/useAdmin' +import { IconHome, IconFileText, IconLayoutGrid, IconTag, IconTags, IconTrash, IconPlug, IconArrowLeft, IconNews, IconWebhook, IconCode, IconPalette, IconDownload, IconTemplate, IconSettings, IconUsers } from '@tabler/icons-react' +import type { Icon } from '@tabler/icons-react' + +interface PluginInfo { + name: string + version: string + adminRoutes?: { path: string; label: string }[] +} + +const roleLevel: Record = { SUPER_ADMIN: 3, ADMIN: 2, MODERATOR: 1 } + +const links: { to: string; label: string; icon: Icon; minLevel: number }[] = [ + { to: '/admin', label: 'Dashboard', icon: IconHome, minLevel: 1 }, + { to: '/admin/posts', label: 'All Posts', icon: IconFileText, minLevel: 1 }, + { to: '/admin/boards', label: 'Boards', icon: IconLayoutGrid, minLevel: 2 }, + { to: '/admin/categories', label: 'Categories', icon: IconTag, minLevel: 1 }, + { to: '/admin/tags', label: 'Tags', icon: IconTags, minLevel: 1 }, + { to: '/admin/team', label: 'Team', icon: IconUsers, minLevel: 2 }, + { to: '/admin/changelog', label: 'Changelog', icon: IconNews, minLevel: 2 }, + { to: '/admin/webhooks', label: 'Webhooks', icon: IconWebhook, minLevel: 2 }, + { to: '/admin/statuses', label: 'Custom Statuses', icon: IconPalette, minLevel: 2 }, + { to: '/admin/templates', label: 'Templates', icon: IconTemplate, minLevel: 2 }, + { to: '/admin/embed', label: 'Embed Widget', icon: IconCode, minLevel: 2 }, + { to: '/admin/export', label: 'Export Data', icon: IconDownload, minLevel: 2 }, + { to: '/admin/data-retention', label: 'Data Retention', icon: IconTrash, minLevel: 1 }, + { to: '/admin/settings', label: 'Branding', icon: IconSettings, minLevel: 3 }, +] + +export default function AdminSidebar() { + const location = useLocation() + const admin = useAdmin() + const [plugins, setPlugins] = useState([]) + + useEffect(() => { + api.get('/plugins/active').then(setPlugins).catch(() => {}) + }, []) + + const pluginLinks = plugins.flatMap((p) => + (p.adminRoutes ?? []).map((r) => ({ + to: `/admin/plugins${r.path}`, + label: r.label, + })) + ) + + const currentLevel = roleLevel[admin.role as string] ?? 0 + const visibleLinks = links.filter(l => currentLevel >= l.minLevel) + + const isActive = (path: string) => + path === '/admin' ? location.pathname === '/admin' : location.pathname.startsWith(path) + + return ( + + ) +} diff --git a/packages/web/src/components/Avatar.tsx b/packages/web/src/components/Avatar.tsx new file mode 100644 index 0000000..28f00e9 --- /dev/null +++ b/packages/web/src/components/Avatar.tsx @@ -0,0 +1,72 @@ +import { useMemo } from 'react' + +function hashCode(str: string): number { + let hash = 0 + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0 + } + return Math.abs(hash) +} + +interface AvatarProps { + userId: string + name?: string | null + avatarUrl?: string | null + size?: number +} + +export default function Avatar({ userId, name, avatarUrl, size = 28 }: AvatarProps) { + if (avatarUrl) { + return ( + {name + ) + } + + return +} + +function GeneratedAvatar({ id, size }: { id: string; size: number }) { + const { hue, shapes } = useMemo(() => { + let hash = 0 + for (let i = 0; i < id.length; i++) hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0 + const h = Math.abs(hash % 360) + const s: { x: number; y: number; r: number; op: number }[] = [] + for (let i = 0; i < 4; i++) { + const seed = Math.abs((hash >> (i * 4)) % 256) + s.push({ + x: 4 + (seed % 24), + y: 4 + ((seed * 7) % 24), + r: 3 + (seed % 6), + op: 0.3 + (seed % 5) * 0.15, + }) + } + return { hue: h, shapes: s } + }, [id]) + + return ( + + ) +} diff --git a/packages/web/src/components/BoardIcon.tsx b/packages/web/src/components/BoardIcon.tsx new file mode 100644 index 0000000..ebb1116 --- /dev/null +++ b/packages/web/src/components/BoardIcon.tsx @@ -0,0 +1,50 @@ +import { renderIcon } from './IconPicker' + +export default function BoardIcon({ + name, + iconName, + iconColor, + size = 32, +}: { + name: string + iconName?: string | null + iconColor?: string | null + size?: number +}) { + const color = iconColor || 'var(--accent)' + const fontSize = size * 0.45 + + if (iconName) { + return ( +
+ {renderIcon(iconName, size * 0.55, color)} +
+ ) + } + + return ( +
+ {name.charAt(0).toUpperCase()} +
+ ) +} diff --git a/packages/web/src/components/CommandPalette.tsx b/packages/web/src/components/CommandPalette.tsx index 8891362..28676ad 100644 --- a/packages/web/src/components/CommandPalette.tsx +++ b/packages/web/src/components/CommandPalette.tsx @@ -1,29 +1,60 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { api } from '../lib/api' +import { useFocusTrap } from '../hooks/useFocusTrap' +import { IconSearch, IconBug, IconBulb } from '@tabler/icons-react' +import BoardIcon from './BoardIcon' +import StatusBadge from './StatusBadge' +import Avatar from './Avatar' -interface SearchResult { - type: 'post' | 'board' +interface BoardResult { + type: 'board' id: string title: string - slug?: string - boardSlug?: string + slug: string + iconName: string | null + iconColor: string | null + description: string | null + postCount: number } +interface PostResult { + type: 'post' + id: string + title: string + postType: 'FEATURE_REQUEST' | 'BUG_REPORT' + status: string + voteCount: number + commentCount: number + boardSlug: string + boardName: string + boardIconName: string | null + boardIconColor: string | null + author: { id: string; displayName: string | null; avatarUrl: string | null } | null + createdAt: string +} + +type FlatResult = (BoardResult | PostResult) + export default function CommandPalette() { const [open, setOpen] = useState(false) const [query, setQuery] = useState('') - const [results, setResults] = useState([]) + const [boards, setBoards] = useState([]) + const [posts, setPosts] = useState([]) const [selected, setSelected] = useState(0) const [loading, setLoading] = useState(false) const inputRef = useRef(null) + const trapRef = useFocusTrap(open) const nav = useNavigate() + const allResults: FlatResult[] = [...boards, ...posts] + const toggle = useCallback(() => { setOpen((v) => { if (!v) { setQuery('') - setResults([]) + setBoards([]) + setPosts([]) setSelected(0) } return !v @@ -52,17 +83,20 @@ export default function CommandPalette() { useEffect(() => { if (!query.trim()) { - setResults([]) + setBoards([]) + setPosts([]) return } const t = setTimeout(async () => { setLoading(true) try { - const res = await api.get(`/search?q=${encodeURIComponent(query)}`) - setResults(res) + const res = await api.get<{ boards: BoardResult[]; posts: PostResult[] }>(`/search?q=${encodeURIComponent(query)}`) + setBoards(res.boards) + setPosts(res.posts) setSelected(0) } catch { - setResults([]) + setBoards([]) + setPosts([]) } finally { setLoading(false) } @@ -70,7 +104,7 @@ export default function CommandPalette() { return () => clearTimeout(t) }, [query]) - const navigate = (r: SearchResult) => { + const go = (r: FlatResult) => { if (r.type === 'board') { nav(`/b/${r.slug}`) } else { @@ -82,19 +116,17 @@ export default function CommandPalette() { const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault() - setSelected((s) => Math.min(s + 1, results.length - 1)) + setSelected((s) => Math.min(s + 1, allResults.length - 1)) } else if (e.key === 'ArrowUp') { e.preventDefault() setSelected((s) => Math.max(s - 1, 0)) - } else if (e.key === 'Enter' && results[selected]) { - navigate(results[selected]) + } else if (e.key === 'Enter' && allResults[selected]) { + go(allResults[selected]) } } if (!open) return null - const boards = results.filter((r) => r.type === 'board') - const posts = results.filter((r) => r.type === 'post') let idx = -1 return ( @@ -102,34 +134,49 @@ export default function CommandPalette() { className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]" onClick={() => setOpen(false)} > - {/* Backdrop */}
- {/* Modal */}
e.stopPropagation()} > -
- - - +

Search

+
+ setQuery(e.target.value)} onKeyDown={onKeyDown} - placeholder="Search posts and boards..." - className="flex-1 bg-transparent outline-none text-sm" - style={{ color: 'var(--text)', fontFamily: 'var(--font-body)' }} + placeholder="Search posts, feedback, and boards..." + className="flex-1 bg-transparent outline-none" + style={{ color: 'var(--text)', fontFamily: 'var(--font-body)', fontSize: 'var(--text-sm)' }} + aria-label="Search" /> ESC @@ -137,46 +184,52 @@ export default function CommandPalette() {
{loading && ( -
+
Searching...
)} - {!loading && query && results.length === 0 && ( -
+ {!loading && query && allResults.length === 0 && ( +
No results for "{query}"
)} {!loading && !query && ( -
+
Start typing to search...
)} {boards.length > 0 && (
-
+
Boards
- {boards.map((r) => { + {boards.map((b) => { idx++ const i = idx return ( ) })} @@ -185,27 +238,50 @@ export default function CommandPalette() { {posts.length > 0 && (
-
+
Posts
- {posts.map((r) => { + {posts.map((p) => { idx++ const i = idx return ( ) })} @@ -214,12 +290,12 @@ export default function CommandPalette() {
- ↑↓ navigate - Enter open - Esc close + ↑↓ navigate + Enter open + Esc close
diff --git a/packages/web/src/components/Dropdown.tsx b/packages/web/src/components/Dropdown.tsx new file mode 100644 index 0000000..0f2c171 --- /dev/null +++ b/packages/web/src/components/Dropdown.tsx @@ -0,0 +1,253 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import { IconChevronDown, IconCheck, IconSearch } from '@tabler/icons-react' + +interface Option { + value: string + label: string +} + +function fuzzyMatch(text: string, query: string): boolean { + const lower = text.toLowerCase() + const q = query.toLowerCase() + let qi = 0 + for (let i = 0; i < lower.length && qi < q.length; i++) { + if (lower[i] === q[qi]) qi++ + } + return qi === q.length +} + +export default function Dropdown({ + value, + options, + onChange, + placeholder = 'Select...', + searchable = false, + 'aria-label': ariaLabel, +}: { + value: string + options: Option[] + onChange: (value: string) => void + placeholder?: string + searchable?: boolean + 'aria-label'?: string +}) { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [activeIndex, setActiveIndex] = useState(-1) + const ref = useRef(null) + const triggerRef = useRef(null) + const searchRef = useRef(null) + const listRef = useRef(null) + const selected = options.find((o) => o.value === value) + + const filtered = searchable && query + ? options.filter((o) => fuzzyMatch(o.label, query)) + : options + + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + useEffect(() => { + if (open && searchable) { + setQuery('') + setTimeout(() => searchRef.current?.focus(), 0) + } + if (open) { + const idx = filtered.findIndex((o) => o.value === value) + setActiveIndex(idx >= 0 ? idx : 0) + } + }, [open]) + + useEffect(() => { + if (!open || activeIndex < 0) return + const list = listRef.current + if (!list) return + const item = list.children[activeIndex] as HTMLElement | undefined + item?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, open]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!open) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setOpen(true) + } + return + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setActiveIndex((i) => Math.min(i + 1, filtered.length - 1)) + break + case 'ArrowUp': + e.preventDefault() + setActiveIndex((i) => Math.max(i - 1, 0)) + break + case 'Enter': + case ' ': + if (!searchable || e.key === 'Enter') { + e.preventDefault() + if (activeIndex >= 0 && filtered[activeIndex]) { + onChange(filtered[activeIndex].value) + setOpen(false) + triggerRef.current?.focus() + } + } + break + case 'Escape': + e.preventDefault() + setOpen(false) + triggerRef.current?.focus() + break + case 'Home': + e.preventDefault() + setActiveIndex(0) + break + case 'End': + e.preventDefault() + setActiveIndex(filtered.length - 1) + break + } + }, [open, filtered, activeIndex, onChange, searchable]) + + const listboxId = 'dropdown-listbox' + const activeId = activeIndex >= 0 ? `dropdown-opt-${activeIndex}` : undefined + + return ( +
+ + + {open && ( +
+ {searchable && ( +
+
+
+
+ )} +
+ {filtered.length === 0 && ( +
+ No matches +
+ )} + {filtered.map((opt, i) => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/packages/web/src/components/EditHistoryModal.tsx b/packages/web/src/components/EditHistoryModal.tsx new file mode 100644 index 0000000..f42a2ee --- /dev/null +++ b/packages/web/src/components/EditHistoryModal.tsx @@ -0,0 +1,157 @@ +import { IconX, IconHistory } from '@tabler/icons-react' +import { useFocusTrap } from '../hooks/useFocusTrap' +import Markdown from './Markdown' + +interface EditEntry { + id: string + previousTitle?: string | null + previousDescription?: Record | null + previousBody?: string | null + editedBy?: { id: string; displayName: string | null } | null + createdAt: string +} + +interface Props { + open: boolean + onClose: () => void + entries: EditEntry[] + type: 'post' | 'comment' + isAdmin?: boolean + onRollback?: (editHistoryId: string) => void +} + +export default function EditHistoryModal({ open, onClose, entries, type, isAdmin, onRollback }: Props) { + const trapRef = useFocusTrap(open) + + if (!open) return null + + return ( +
+
+
e.stopPropagation()} + onKeyDown={(e) => e.key === 'Escape' && onClose()} + > + {/* Header */} +
+

+ Edit history +

+ +
+ + {/* Body */} +
+ {entries.length === 0 ? ( +

No edit history found.

+ ) : ( +
+ {entries.map((edit) => ( +
+
+ + {isAdmin && onRollback && ( + + )} +
+ + {type === 'post' && ( + <> + {edit.previousTitle && ( +
+ {edit.previousTitle} +
+ )} + {edit.previousDescription && ( +
+ {Object.entries(edit.previousDescription).map(([k, v]) => ( +
+ + {k} + +
+ {typeof v === 'string' ? v : ''} +
+
+ ))} +
+ )} + + )} + + {type === 'comment' && edit.previousBody && ( +
+ {edit.previousBody} +
+ )} +
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/packages/web/src/components/EmptyState.tsx b/packages/web/src/components/EmptyState.tsx index 75ec918..723c810 100644 --- a/packages/web/src/components/EmptyState.tsx +++ b/packages/web/src/components/EmptyState.tsx @@ -1,68 +1,54 @@ +import { IconSpeakerphone, IconSearch, IconActivity, IconFileText } from '@tabler/icons-react' +import type { Icon } from '@tabler/icons-react' + interface Props { title?: string message?: string actionLabel?: string onAction?: () => void + icon?: Icon } export default function EmptyState({ title = 'Nothing here yet', - message = 'Be the first to share feedback', - actionLabel = 'Create a post', + message = 'Be the first to share your thoughts and ideas', + actionLabel = 'Share feedback', onAction, + icon: CustomIcon, }: Props) { - return ( -
- {/* Megaphone SVG */} - - - - - - - + const Icon = CustomIcon || IconSpeakerphone -

+
+ +
+ +

{title} -

-

+ +

{message}

{onAction && ( diff --git a/packages/web/src/components/FileUpload.tsx b/packages/web/src/components/FileUpload.tsx new file mode 100644 index 0000000..c0ef5f9 --- /dev/null +++ b/packages/web/src/components/FileUpload.tsx @@ -0,0 +1,196 @@ +import { useState, useRef, useCallback } from 'react' +import { IconUpload, IconX, IconLoader2 } from '@tabler/icons-react' + +interface Attachment { + id: string + filename: string + mimeType: string + size: number +} + +interface Props { + attachmentIds: string[] + onChange: (ids: string[]) => void +} + +const MAX_SIZE = 5 * 1024 * 1024 +const ACCEPT = 'image/jpeg,image/png,image/gif,image/webp' + +export default function FileUpload({ attachmentIds, onChange }: Props) { + const [previews, setPreviews] = useState>(new Map()) + const [filenames, setFilenames] = useState>(new Map()) + const [uploading, setUploading] = useState(false) + const [error, setError] = useState('') + const [dragOver, setDragOver] = useState(false) + const inputRef = useRef(null) + + const upload = useCallback(async (file: File) => { + if (file.size > MAX_SIZE) { + setError('File too large (max 5MB)') + return null + } + if (!file.type.match(/^image\/(jpeg|png|gif|webp)$/)) { + setError('Only jpg, png, gif, webp images are allowed') + return null + } + + const form = new FormData() + form.append('file', file) + + const res = await fetch('/api/v1/attachments', { + method: 'POST', + credentials: 'include', + body: form, + }) + + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error((body as any).error || 'Upload failed') + } + + return (await res.json()) as Attachment + }, []) + + const handleFiles = useCallback(async (files: FileList | File[]) => { + setError('') + const toUpload = Array.from(files).slice(0, 10 - attachmentIds.length) + if (!toUpload.length) return + + setUploading(true) + const newIds: string[] = [] + const newPreviews = new Map(previews) + const newFilenames = new Map(filenames) + + for (const file of toUpload) { + try { + const att = await upload(file) + if (att) { + newIds.push(att.id) + newPreviews.set(att.id, URL.createObjectURL(file)) + newFilenames.set(att.id, att.filename) + } + } catch (err: any) { + setError(err.message || 'Upload failed') + } + } + + setPreviews(newPreviews) + setFilenames(newFilenames) + onChange([...attachmentIds, ...newIds]) + setUploading(false) + }, [attachmentIds, previews, onChange, upload]) + + const remove = useCallback(async (id: string) => { + await fetch(`/api/v1/attachments/${id}`, { + method: 'DELETE', + credentials: 'include', + }).catch(() => {}) + + const url = previews.get(id) + if (url) URL.revokeObjectURL(url) + + const next = new Map(previews) + next.delete(id) + setPreviews(next) + const nextNames = new Map(filenames) + nextNames.delete(id) + setFilenames(nextNames) + onChange(attachmentIds.filter((a) => a !== id)) + }, [attachmentIds, previews, filenames, onChange]) + + const onDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + setDragOver(false) + if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files) + }, [handleFiles]) + + return ( +
+
inputRef.current?.click()} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); inputRef.current?.click() } }} + onDragOver={(e) => { e.preventDefault(); setDragOver(true) }} + onDragLeave={() => setDragOver(false)} + onDrop={onDrop} + style={{ + border: `2px dashed ${dragOver ? 'var(--accent)' : 'var(--border)'}`, + borderRadius: 'var(--radius-md)', + padding: '16px', + textAlign: 'center', + cursor: 'pointer', + background: dragOver ? 'var(--accent-subtle)' : 'transparent', + transition: 'all var(--duration-fast) ease-out', + }} + > + { if (e.target.files) handleFiles(e.target.files); e.target.value = '' }} + /> +
+ {uploading ? ( + + ) : ( + + )} + {uploading ? 'Uploading...' : 'Drop images or click to upload'} +
+
+ + {error && ( +
{error}
+ )} + + {attachmentIds.length > 0 && ( +
+ {attachmentIds.map((id) => ( +
+ {filenames.get(id) + +
+ ))} +
+ )} +
+ ) +} diff --git a/packages/web/src/components/IconPicker.tsx b/packages/web/src/components/IconPicker.tsx new file mode 100644 index 0000000..5dbd980 --- /dev/null +++ b/packages/web/src/components/IconPicker.tsx @@ -0,0 +1,283 @@ +import { useState, useEffect, useMemo, useRef, useCallback } from 'react' +import * as tablerIcons from '@tabler/icons-react' +import { IconSearch, IconX } from '@tabler/icons-react' + +const ALL_ICON_NAMES: string[] = Object.keys(tablerIcons).filter( + (k) => k.startsWith('Icon') && !k.includes('Filled') && k !== 'Icon' +) + +const PRESET_COLORS = [ + '#F59E0B', '#EF4444', '#22C55E', '#3B82F6', '#8B5CF6', + '#EC4899', '#06B6D4', '#F97316', '#14B8A6', '#6366F1', + '#A855F7', '#64748B', '#E11D48', '#0EA5E9', '#84CC16', + '#D946EF', '#FB923C', '#2DD4BF', '#818CF8', '#F43F5E', + '#34D399', +] + +const COLOR_NAMES: Record = { + '#F59E0B': 'Amber', '#EF4444': 'Red', '#22C55E': 'Green', '#3B82F6': 'Blue', '#8B5CF6': 'Violet', + '#EC4899': 'Pink', '#06B6D4': 'Cyan', '#F97316': 'Orange', '#14B8A6': 'Teal', '#6366F1': 'Indigo', + '#A855F7': 'Purple', '#64748B': 'Slate', '#E11D48': 'Rose', '#0EA5E9': 'Sky', '#84CC16': 'Lime', + '#D946EF': 'Fuchsia', '#FB923C': 'Light orange', '#2DD4BF': 'Mint', '#818CF8': 'Periwinkle', '#F43F5E': 'Coral', + '#34D399': 'Emerald', +} + +// common/popular icons shown first before search +const POPULAR = [ + 'IconHome', 'IconStar', 'IconHeart', 'IconMessage', 'IconBell', + 'IconSettings', 'IconUser', 'IconFolder', 'IconTag', 'IconFlag', + 'IconBulb', 'IconRocket', 'IconCode', 'IconBug', 'IconShield', + 'IconLock', 'IconEye', 'IconMusic', 'IconPhoto', 'IconBook', + 'IconCalendar', 'IconClock', 'IconMap', 'IconGlobe', 'IconCloud', + 'IconBolt', 'IconFlame', 'IconDiamond', 'IconCrown', 'IconTrophy', + 'IconPuzzle', 'IconBrush', 'IconPalette', 'IconWand', 'IconSparkles', + 'IconTarget', 'IconCompass', 'IconAnchor', 'IconFeather', 'IconLeaf', + 'IconDroplet', 'IconSun', 'IconMoon', 'IconZap', 'IconActivity', + 'IconChartBar', 'IconDatabase', 'IconServer', 'IconCpu', 'IconWifi', + 'IconBriefcase', 'IconGift', 'IconTruck', 'IconShoppingCart', 'IconCreditCard', + 'IconMicrophone', 'IconHeadphones', 'IconCamera', 'IconVideo', 'IconGamepad', +] + +function renderIcon(name: string, size: number, color?: string) { + const Comp = (tablerIcons as Record)[name] + if (!Comp) return null + return +} + +export { renderIcon } + +function IconGrid({ names, value, currentColor, onPick }: { names: string[], value: string | null, currentColor: string, onPick: (name: string) => void }) { + return ( +
+ {names.map((name) => ( + + ))} +
+ ) +} + +export default function IconPicker({ + value, + color, + onChangeIcon, + onChangeColor, +}: { + value: string | null + color: string | null + onChangeIcon: (name: string | null) => void + onChangeColor: (color: string | null) => void +}) { + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const [visibleCount, setVisibleCount] = useState(120) + const ref = useRef(null) + const gridRef = useRef(null) + + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + useEffect(() => { + if (!open) return + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, [open]) + + // reset scroll on search change + useEffect(() => { + setVisibleCount(120) + if (gridRef.current) gridRef.current.scrollTop = 0 + }, [search]) + + const popular = useMemo(() => POPULAR.filter((n) => ALL_ICON_NAMES.includes(n)), []) + const allExceptPopular = useMemo(() => ALL_ICON_NAMES.filter((n) => !POPULAR.includes(n)), []) + + const filtered = useMemo(() => { + if (!search.trim()) return null + const q = search.toLowerCase().replace(/\s+/g, '') + return ALL_ICON_NAMES.filter((name) => + name.toLowerCase().replace('icon', '').includes(q) + ) + }, [search]) + + const totalCount = filtered ? filtered.length : popular.length + allExceptPopular.length + + const handleScroll = useCallback(() => { + const el = gridRef.current + if (!el) return + if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) { + setVisibleCount((c) => Math.min(c + 120, totalCount)) + } + }, [totalCount]) + + const currentColor = color || 'var(--accent)' + + return ( +
+
+ + )} + +
+ + {open && ( +
+ {/* Search */} +
+
+ + setSearch(e.target.value)} + placeholder="Search icons..." + aria-label="Search icons" + autoFocus + style={{ + width: '100%', + padding: '8px 0', + background: 'transparent', + border: 'none', + outline: 'none', + color: 'var(--text)', + fontSize: 'var(--text-xs)', + fontFamily: 'var(--font-body)', + }} + /> + {search && ( + + )} +
+
+ + {/* Color picker */} +
+ Color + {PRESET_COLORS.map((c) => ( +
+ + {/* Icon grid */} +
+ {filtered && filtered.length === 0 ? ( +
+ No icons found +
+ ) : filtered ? ( + <> +
+ {filtered.length} results +
+ { onChangeIcon(n); setOpen(false) }} /> + + ) : ( + <> +
+ Popular +
+ { onChangeIcon(n); setOpen(false) }} /> +
+
+ All icons +
+ { onChangeIcon(n); setOpen(false) }} /> + + )} +
+
+ )} +
+ ) +} diff --git a/packages/web/src/components/IdentityBanner.tsx b/packages/web/src/components/IdentityBanner.tsx index 14876c5..bb865db 100644 --- a/packages/web/src/components/IdentityBanner.tsx +++ b/packages/web/src/components/IdentityBanner.tsx @@ -1,73 +1,237 @@ import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' +import { useAuth } from '../hooks/useAuth' +import { useFocusTrap } from '../hooks/useFocusTrap' +import { IconLock, IconFingerprint, IconShieldCheck } from '@tabler/icons-react' const DISMISSED_KEY = 'echoboard-identity-ack' +const UPGRADE_DISMISSED_KEY = 'echoboard-upgrade-dismissed' export default function IdentityBanner({ onRegister }: { onRegister: () => void }) { const [visible, setVisible] = useState(false) + const [showMore, setShowMore] = useState(false) + const [upgradeNudge, setUpgradeNudge] = useState(false) + const auth = useAuth() + const trapRef = useFocusTrap(visible) useEffect(() => { if (!localStorage.getItem(DISMISSED_KEY)) { - const t = setTimeout(() => setVisible(true), 800) + const t = setTimeout(() => setVisible(true), 400) return () => clearTimeout(t) } - }, []) - if (!visible) return null + if (!auth.isPasskeyUser && !auth.user?.hasRecoveryCode && auth.isAuthenticated) { + const dismissed = localStorage.getItem(UPGRADE_DISMISSED_KEY) + if (dismissed) { + const ts = parseInt(dismissed, 10) + if (Date.now() - ts < 7 * 24 * 60 * 60 * 1000) return + } + import('../lib/api').then(({ api }) => { + api.get<{ length: number } & any[]>('/me/posts').then((posts) => { + if (Array.isArray(posts) && posts.length >= 3) { + setUpgradeNudge(true) + } + }).catch(() => {}) + }) + } + }, [auth.isPasskeyUser, auth.isAuthenticated, auth.user?.hasRecoveryCode]) const dismiss = () => { localStorage.setItem(DISMISSED_KEY, '1') setVisible(false) } - return ( -
+ const dismissUpgrade = () => { + localStorage.setItem(UPGRADE_DISMISSED_KEY, String(Date.now())) + setUpgradeNudge(false) + } + + // Upgrade nudge - subtle toast + if (upgradeNudge && !visible) { + return (
-
-
+ You have been active! If you clear your cookies, you'll lose access to your posts. Save your identity with a passkey, or grab a recovery code. +

+
+
-
-

+ Save identity + + + + Recovery code + + +

+
+ ) + } + + if (!visible) return null + + // First-visit modal (centered) + return ( +
+
+ +
e.key === 'Escape' && dismiss()} + > +
+ +
+ +

+ Before you dive in +

+ +
+

+ How your identity works +

+

+ Your identity on this board starts as a cookie in your browser. It contains a random token - nothing personal. This token ties your posts, votes, and comments to you so you can manage them. +

+
+ +
+

+ Your cookie expires when you close the browser +

+

+ Your identity is a session cookie. Close your browser, clear cookies, switch device - and you lose access to everything you created. Posts stay on the board but become uneditable by you. +

+
+ +
+

+ Want persistent access? +

+

+ Pick a username and save a passkey - no email, no account, no personal data. Your passkey syncs across your devices through your platform's credential manager, so you can always get back to your posts. +

+

+ Browser doesn't support passkeys? You can generate a recovery code instead - a phrase you save that lets you get back to your posts. Visit Settings at any time to set one up. +

+
+ +
+
+
- Your identity is cookie-based - -

- You can post and vote right now - no signup needed. A cookie links your activity. Register a passkey to keep access across devices and browsers. -

-
- - - - Learn more - +

+ What is stored: A random token (SHA-256 hashed on the server). The cookie is session-only and does not persist after you close your browser. If you register, a username and passkey public key (both encrypted at rest). +

+

+ What is never stored: Your email, IP address, browser fingerprint, or any personal information. This site sets exactly one cookie containing your random identifier. No tracking, no analytics. +

+

+ Passkeys explained: Passkeys use the WebAuthn standard. Your device generates a key pair - the private key stays on your device (or syncs via iCloud Keychain, Google Password Manager, Windows Hello). The server only stores the public key. +

+ +
+ + + {!showMore && ( + + )} +
) diff --git a/packages/web/src/components/Markdown.tsx b/packages/web/src/components/Markdown.tsx new file mode 100644 index 0000000..38730ea --- /dev/null +++ b/packages/web/src/components/Markdown.tsx @@ -0,0 +1,36 @@ +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import remarkBreaks from 'remark-breaks' + +export default function Markdown({ children }: { children: string }) { + return ( +
+ { + const safe = href && /^https?:\/\//i.test(href) + return safe + ? {children} (opens in new tab) + : {children} + }, + img: ({ src, alt }) => { + const safe = src && /^https?:\/\//i.test(src) + return safe + ? {alt + : {alt || ''} + }, + script: () => null, + iframe: () => null, + form: () => null, + input: () => null, + object: () => null, + embed: () => null, + style: () => null, + }} + > + {children} + +
+ ) +} diff --git a/packages/web/src/components/MarkdownEditor.tsx b/packages/web/src/components/MarkdownEditor.tsx new file mode 100644 index 0000000..c99c82c --- /dev/null +++ b/packages/web/src/components/MarkdownEditor.tsx @@ -0,0 +1,499 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import { + IconBold, IconItalic, IconStrikethrough, IconLink, IconCode, + IconList, IconListNumbers, IconQuote, IconPhoto, IconTable, + IconH1, IconH2, IconH3, IconMinus, IconCheckbox, IconSourceCode, + IconEye, IconPencil, +} from '@tabler/icons-react' +import Markdown from './Markdown' +import Avatar from './Avatar' +import { api } from '../lib/api' + +interface MentionUser { + id: string + username: string + avatarUrl: string | null +} + +interface Props { + value: string + onChange: (value: string) => void + placeholder?: string + rows?: number + autoFocus?: boolean + preview?: boolean + ariaRequired?: boolean + ariaLabel?: string + mentions?: boolean +} + +type Action = + | { prefix: string; suffix: string } + | { linePrefix: string } + | { block: string } + +interface ToolbarItem { + icon: typeof IconBold + title: string + action: Action +} + +type ToolbarEntry = ToolbarItem | 'table' +type ToolbarGroup = ToolbarEntry[] + +const toolbar: ToolbarGroup[] = [ + [ + { icon: IconH1, title: 'Heading 1', action: { linePrefix: '# ' } }, + { icon: IconH2, title: 'Heading 2', action: { linePrefix: '## ' } }, + { icon: IconH3, title: 'Heading 3', action: { linePrefix: '### ' } }, + ], + [ + { icon: IconBold, title: 'Bold', action: { prefix: '**', suffix: '**' } }, + { icon: IconItalic, title: 'Italic', action: { prefix: '*', suffix: '*' } }, + { icon: IconStrikethrough, title: 'Strikethrough', action: { prefix: '~~', suffix: '~~' } }, + ], + [ + { icon: IconCode, title: 'Inline code', action: { prefix: '`', suffix: '`' } }, + { icon: IconSourceCode, title: 'Code block', action: { block: '```\n\n```' } }, + { icon: IconQuote, title: 'Blockquote', action: { linePrefix: '> ' } }, + ], + [ + { icon: IconList, title: 'Bullet list', action: { linePrefix: '- ' } }, + { icon: IconListNumbers, title: 'Numbered list', action: { linePrefix: '1. ' } }, + { icon: IconCheckbox, title: 'Task list', action: { linePrefix: '- [ ] ' } }, + ], + [ + { icon: IconLink, title: 'Link', action: { prefix: '[', suffix: '](url)' } }, + { icon: IconPhoto, title: 'Image', action: { prefix: '![', suffix: '](url)' } }, + { icon: IconMinus, title: 'Horizontal rule', action: { block: '---' } }, + 'table', + ], +] + +const GRID_COLS = 8 +const GRID_ROWS = 6 +const CELL = 44 +const GAP = 1 + +function buildTable(cols: number, rows: number): string { + const header = '| ' + Array.from({ length: cols }, (_, i) => `Column ${i + 1}`).join(' | ') + ' |' + const sep = '| ' + Array.from({ length: cols }, () => '---').join(' | ') + ' |' + const dataRows = Array.from({ length: rows }, () => + '| ' + Array.from({ length: cols }, () => ' ').join(' | ') + ' |' + ) + return [header, sep, ...dataRows].join('\n') +} + +function TablePicker({ onSelect, onClose }: { onSelect: (cols: number, rows: number) => void; onClose: () => void }) { + const [hover, setHover] = useState<[number, number]>([0, 0]) + const popRef = useRef(null) + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (popRef.current && !popRef.current.contains(e.target as Node)) onClose() + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [onClose]) + + return ( +
+
+ {Array.from({ length: GRID_ROWS * GRID_COLS }, (_, i) => { + const col = i % GRID_COLS + const row = Math.floor(i / GRID_COLS) + const active = col < hover[0] && row < hover[1] + return ( +
+
+ {hover[0] > 0 && hover[1] > 0 ? `${hover[0]} x ${hover[1]}` : 'Select size'} +
+
+ ) +} + +export default function MarkdownEditor({ value, onChange, placeholder, rows = 3, autoFocus, preview: enablePreview, ariaRequired, ariaLabel, mentions: enableMentions }: Props) { + const ref = useRef(null) + const [previewing, setPreviewing] = useState(false) + const [tablePicker, setTablePicker] = useState(false) + const [mentionUsers, setMentionUsers] = useState([]) + const [mentionActive, setMentionActive] = useState(0) + const mentionDebounce = useRef>() + const mentionDropdownRef = useRef(null) + const [mentionQuery, setMentionQuery] = useState('') + + const getMentionQuery = useCallback((): string | null => { + const ta = ref.current + if (!ta || !enableMentions) return null + const pos = ta.selectionStart + const before = value.slice(0, pos) + const match = before.match(/@([a-zA-Z0-9_]{2,30})$/) + return match ? match[1] : null + }, [value, enableMentions]) + + useEffect(() => { + if (!enableMentions) return + const q = getMentionQuery() + if (!q || q.length < 2) { + setMentionUsers([]) + setMentionQuery('') + return + } + if (q === mentionQuery) return + setMentionQuery(q) + if (mentionDebounce.current) clearTimeout(mentionDebounce.current) + mentionDebounce.current = setTimeout(() => { + api.get<{ users: MentionUser[] }>(`/users/search?q=${encodeURIComponent(q)}`) + .then((r) => { setMentionUsers(r.users); setMentionActive(0) }) + .catch(() => setMentionUsers([])) + }, 200) + return () => { if (mentionDebounce.current) clearTimeout(mentionDebounce.current) } + }, [value, getMentionQuery, enableMentions]) + + const insertMention = (username: string) => { + const ta = ref.current + if (!ta) return + const pos = ta.selectionStart + const before = value.slice(0, pos) + const after = value.slice(pos) + const atIdx = before.lastIndexOf('@') + if (atIdx === -1) return + const newVal = before.slice(0, atIdx) + '@' + username + ' ' + after + onChange(newVal) + setMentionUsers([]) + setMentionQuery('') + const newPos = atIdx + username.length + 2 + requestAnimationFrame(() => { ta.focus(); ta.setSelectionRange(newPos, newPos) }) + } + + useEffect(() => { + if (!enableMentions) return + const handler = (e: MouseEvent) => { + if (mentionDropdownRef.current && !mentionDropdownRef.current.contains(e.target as Node)) { + setMentionUsers([]) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [enableMentions]) + + const insertBlock = (block: string) => { + const ta = ref.current + if (!ta) return + const start = ta.selectionStart + const end = ta.selectionEnd + const before = value.slice(0, start) + const after = value.slice(end) + const needsBefore = before.length > 0 && !before.endsWith('\n\n') + const needsAfter = after.length > 0 && !after.startsWith('\n') + const prefix = needsBefore ? (before.endsWith('\n') ? '\n' : '\n\n') : '' + const suffix = needsAfter ? '\n' : '' + const newValue = before + prefix + block + suffix + after + const firstNewline = block.indexOf('\n') + const blockStart = before.length + prefix.length + const cursorPos = firstNewline > -1 ? blockStart + firstNewline + 1 : blockStart + block.length + onChange(newValue) + requestAnimationFrame(() => { + ta.focus() + ta.setSelectionRange(cursorPos, cursorPos) + }) + } + + const apply = (action: Action) => { + const ta = ref.current + if (!ta) return + + const start = ta.selectionStart + const end = ta.selectionEnd + const selected = value.slice(start, end) + + let newValue: string + let cursorPos: number + + if ('block' in action) { + insertBlock(action.block) + return + } else if ('linePrefix' in action) { + const lines = selected ? selected.split('\n') : [''] + const prefixed = lines.map((l) => action.linePrefix + l).join('\n') + newValue = value.slice(0, start) + prefixed + value.slice(end) + cursorPos = start + prefixed.length + } else { + const wrapped = action.prefix + (selected || 'text') + action.suffix + newValue = value.slice(0, start) + wrapped + value.slice(end) + if (selected) { + cursorPos = start + wrapped.length + } else { + cursorPos = start + action.prefix.length + 4 + } + } + + onChange(newValue) + requestAnimationFrame(() => { + ta.focus() + if (!selected && 'prefix' in action && 'suffix' in action) { + ta.setSelectionRange(start + action.prefix.length, start + action.prefix.length + 4) + } else { + ta.setSelectionRange(cursorPos, cursorPos) + } + }) + } + + const btnStyle = { + color: 'var(--text-tertiary)', + borderRadius: 'var(--radius-sm)', + transition: 'color var(--duration-fast) ease-out, background var(--duration-fast) ease-out', + } + + const hover = (e: React.MouseEvent | React.FocusEvent, enter: boolean) => { + e.currentTarget.style.color = enter ? 'var(--text)' : 'var(--text-tertiary)' + e.currentTarget.style.background = enter ? 'var(--surface-hover)' : 'transparent' + } + + return ( +
+
+ {toolbar.map((group, gi) => ( +
+ {gi > 0 && ( +
+ )} + {group.map((entry) => { + if (entry === 'table') { + return ( +
+ + {tablePicker && ( + { + setPreviewing(false) + insertBlock(buildTable(cols, rows)) + }} + onClose={() => setTablePicker(false)} + /> + )} +
+ ) + } + const { icon: Icon, title, action } = entry + return ( + + ) + })} +
+ ))} + + {enablePreview && ( + <> +
+ + + )} +
+ + {previewing ? ( +
+ {value.trim() ? ( + {value} + ) : ( + + Nothing to preview + + )} +
+ ) : ( +
+