diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e32e1b6 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +VITE_API_BASE=/api +VITE_SITE_URL=http://localhost:5173 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7c11afb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 + +updates: + - package-ecosystem: npm + directory: / + target-branch: codex/cuegrove-site + schedule: + interval: weekly + day: monday + time: "04:10" + timezone: Asia/Shanghai + open-pull-requests-limit: 5 + groups: + production-dependencies: + dependency-type: production + development-dependencies: + dependency-type: development + commit-message: + prefix: deps + + - package-ecosystem: github-actions + directory: / + target-branch: codex/cuegrove-site + schedule: + interval: weekly + day: monday + time: "04:30" + timezone: Asia/Shanghai + open-pull-requests-limit: 5 + commit-message: + prefix: ci diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml new file mode 100644 index 0000000..f3f4149 --- /dev/null +++ b/.github/workflows/supply-chain.yml @@ -0,0 +1,79 @@ +name: Supply chain security + +on: + pull_request: + push: + branches: + - main + - codex/cuegrove-site + schedule: + - cron: '23 19 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: supply-chain-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + dependency-review: + name: Review dependency changes + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Block newly introduced high-risk dependencies + uses: actions/dependency-review-action@v5 + with: + fail-on-severity: high + # RSC/server actions are not present in this static client-only site. + # Time-limited exception: SUPPLY_CHAIN_SECURITY.md (review 2026-08-29). + allow-ghsas: GHSA-qwww-vcr4-c8h2 + + production-audit: + name: Audit production dependencies + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v6 + with: + version: 11.9.0 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - name: Install exactly locked dependencies + run: pnpm install --frozen-lockfile + + - name: Block high and critical production vulnerabilities + run: pnpm audit --prod --audit-level high + + secret-scan: + name: Scan repository history for secrets + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out full repository history + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect hard-coded credentials and tokens + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 446f569..dafa516 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,16 @@ build/ *.xccovreport *.profraw +# CueGrove website +node_modules/ +.pnpm-store/ +dist/ +server/node_modules/ +server/.env +server/uploads/ +.env +npm-debug.log* + # Local Xcode user state xcuserdata/ *.xcuserstate diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..7c5653d --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1 @@ +793108af90a321fe6c7a69b66de2def74189c02a:server/test/mfa.test.js:generic-api-key:20 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cccac51 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +# Project agent instructions + +## Git workflow + +- Never merge a working branch into `main` unless the user explicitly authorizes that specific merge. +- After completing and verifying a requested modification, stage only the files belonging to that task, commit them on the current working branch, and push that branch by default without waiting for a separate push request. +- Never include unrelated or pre-existing user changes in a commit. If the task changes cannot be isolated safely, stop and explain the conflict instead of switching branches or merging. + +## Deployment handoff + +- After completing changes that can be deployed, always include copyable deployment commands in the final response. +- The CueGrove production application is managed as the `cuegrove-api` Node project in the BaoTa panel. +- Never run, recommend, or include PM2 lifecycle or persistence commands for this project, including `pm2 start`, `pm2 stop`, `pm2 restart`, `pm2 reload`, `pm2 startOrReload`, `pm2 delete`, and `pm2 save`. +- Deployment commands should cover only the applicable code update, dependency installation, database migration, tests, frontend build, and health checks. +- After the server-side build is complete, explicitly tell the user: `请在宝塔面板中手动重启 Node 项目 cuegrove-api。` +- Run or suggest public API health checks only after the user has restarted the project in BaoTa. The health endpoint is `https://cuegroveapp.com/api/health`. +- Only include Nginx validation or reload commands when Nginx configuration changed as part of the task. + +The standard CueGrove server update commands are: + +```bash +cd /www/wwwroot/cuegrove +git pull --ff-only origin codex/cuegrove-site +pnpm install --frozen-lockfile +pnpm --dir server migrate +pnpm test +pnpm build +``` + +Then tell the user to restart `cuegrove-api` manually in BaoTa. Do not append PM2 commands. diff --git a/Brand/CueGrove/CueGrove-google-workspace-120.png b/Brand/CueGrove/CueGrove-google-workspace-120.png new file mode 100644 index 0000000..f649836 Binary files /dev/null and b/Brand/CueGrove/CueGrove-google-workspace-120.png differ diff --git a/Brand/CueGrove/CueGrove-icon-512.png b/Brand/CueGrove/CueGrove-icon-512.png new file mode 100644 index 0000000..3be0a42 Binary files /dev/null and b/Brand/CueGrove/CueGrove-icon-512.png differ diff --git a/Brand/CueGrove/CueGrove-icon-master.png b/Brand/CueGrove/CueGrove-icon-master.png new file mode 100644 index 0000000..d3ebfac Binary files /dev/null and b/Brand/CueGrove/CueGrove-icon-master.png differ diff --git a/CUEGROVE_DEPLOY.md b/CUEGROVE_DEPLOY.md new file mode 100644 index 0000000..b2f3839 --- /dev/null +++ b/CUEGROVE_DEPLOY.md @@ -0,0 +1,143 @@ +# CueGrove website deployment + +The CueGrove website is isolated from the PromptDock app and from Mooncci. It uses a Vite static frontend, a PM2-managed Express API, MySQL, and SMTP. + +## 1. Prepare isolated services + +Create a new MySQL database and user for CueGrove. Do not reuse Mooncci tables or credentials. + +```sql +CREATE DATABASE cuegrove CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'cuegrove'@'127.0.0.1' IDENTIFIED BY 'replace-with-a-strong-password'; +GRANT ALL PRIVILEGES ON cuegrove.* TO 'cuegrove'@'127.0.0.1'; +FLUSH PRIVILEGES; +``` + +Copy `server/.env.example` to `server/.env` and configure: + +- the production `SITE_URL` and `ALLOWED_ORIGINS`; +- the isolated database credentials; +- a random `JWT_SECRET` of at least 32 characters; +- the existing Cloudflare Turnstile widget secret as `TURNSTILE_SECRET`; +- a unique secure cookie name and `COOKIE_SECURE=true`; +- optional initial SMTP host and credentials. After the first owner signs in, + SMTP can be moved into the encrypted database-backed settings page. + +Never commit `server/.env`. + +The Turnstile site key is public and embedded in the Early Access form. Its +secret must exist only in `server/.env` as `TURNSTILE_SECRET`. Submissions fail +closed if the secret is missing or Cloudflare cannot verify the browser token. +The application sends the requesting IP to Cloudflare `siteverify` for abuse +verification but does not store the IP in CueGrove's database. + +`DB_PASSWORD`, `JWT_SECRET`, cookie security, and the API port are boot-level +settings and intentionally remain in `server/.env`. The website never returns +these values to a browser. The SMTP password saved in the administrator UI is +encrypted with a key derived from `JWT_SECRET`; if `JWT_SECRET` is rotated, +re-enter the SMTP password in the UI. + +### Google Workspace SMTP relay + +For a server with a fixed public IP, Google Workspace can authenticate SMTP relay +traffic by IP without storing a Google account password. Add the server IP in +Google Admin Console under **Apps > Google Workspace > Gmail > Routing > SMTP +relay service**, restrict senders to your domain, and require TLS. Then use: + +```dotenv +MAIL_ENABLED=true +SMTP_HOST=smtp-relay.gmail.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_AUTH_REQUIRED=false +SMTP_TIMEOUT_MS=20000 +SMTP_HELO_NAME=mail.cuegroveapp.com +SMTP_USER= +SMTP_PASS= +SMTP_FROM=CueGrove +SMTP_REPLY_TO=mooncci@cuegroveapp.com +``` + +Keep `SMTP_AUTH_REQUIRED=true` for providers that authenticate with a username +and password. + +For reliable Google relay delivery, create a DNS-only `A` record for the EHLO +hostname that points to the server public IP, and configure the server +provider's reverse DNS/PTR for that IP to the same hostname. Google can +temporarily reject connections whose EHLO name is not a fully qualified domain +name or whose forward and reverse DNS do not match. + +## 2. Install, migrate, and create the owner + +```bash +pnpm install +pnpm --dir server migrate +read -s CUEGROVE_ADMIN_PASSWORD +export CUEGROVE_ADMIN_PASSWORD +pnpm --dir server create-admin -- --name "CueGrove Owner" --email owner@example.com --role owner +unset CUEGROVE_ADMIN_PASSWORD +pnpm build +``` + +The owner bootstrap command hashes the password with bcrypt and never writes it to a file. + +## 3. Run behind Nginx + +Copy `dist/` to the site root, start the API with: + +```bash +pm2 start server/ecosystem.config.cjs +pm2 save +``` + +Adapt `server/nginx.cuegrove.conf.example`, enable HTTPS, then reload Nginx. The frontend and `/api` must share one origin for the administrator cookie and origin checks. +If the live site already has a Content Security Policy, allow +`https://challenges.cloudflare.com` in `script-src`, `connect-src`, and +`frame-src` so the Turnstile widget can load and complete verification. + +## 4. Finish setup in the administrator UI + +Sign in at `/admin/login`, open Settings, and configure: + +1. the current cohort; +2. the real PromptDock download URL; +3. the feedback URL; +4. the administrator notification email; +5. whether new applications should trigger notification email; +6. SMTP host, port, connection security, mailbox credentials, sender, and + reply-to address. + +Approvals remain disabled until the download URL is present. Use **Send test email** before reviewing applicants. + +Owners can open **Administrators** to create or disable administrator accounts, +change `owner/admin` roles, clear login locks, and set a new password. At least +one active owner is always required. Public registration is intentionally not +available: visitors submit Early Access applications without creating an +account. + +Environment SMTP values are used only as a fallback until an owner saves the +SMTP form. Passwords are encrypted at rest and are never displayed again. + +## Updating an existing BaoTa deployment + +From the project directory, update the same branch and apply all new migrations: + +```bash +cd /www/wwwroot/cuegrove +git pull origin codex/cuegrove-site +pnpm install --frozen-lockfile +pnpm --dir server migrate +pnpm build +``` + +Then restart the `cuegrove-api` Node/PM2 project in BaoTa. Nginx should continue +serving `/www/wwwroot/cuegrove/dist` and proxying `/api/` to +`http://127.0.0.1:3001`. No new public registration route is required. + +## Backup and recovery + +- Back up the CueGrove MySQL database daily and retain at least 14 daily copies. +- Back up `server/.env` separately in an encrypted secrets manager. +- The static frontend can be recreated with `pnpm build`; do not treat `dist/` as the primary backup. +- Test a database restore before opening applications, and again after schema migrations. +- A failed result email does not undo a review decision. Open the application in the admin dashboard and use **Retry decision email**. diff --git a/README.md b/README.md index 544ea75..b5b7f63 100644 --- a/README.md +++ b/README.md @@ -101,3 +101,15 @@ The backup format and SwiftData schema both remain at version 1. Future model ch A free Personal Team supports local development and widget testing. A DMG can be created without a paid account, but public Developer ID signing, notarization, and Mac App Store distribution require the Apple Developer Program. An unsigned or unnotarized DMG may trigger Gatekeeper warnings on other Macs. PromptDock is available under the [MIT License](LICENSE). + +## CueGrove website + +This repository also contains the independent CueGrove brand website and PromptDock Early Access workflow: + +- `src/` contains the bilingual React/Vite public site and protected administrator UI. +- `server/` contains the isolated Express, MySQL, authentication, review, and SMTP service. +- `Brand/CueGrove/` contains the CueGrove source brand assets. + +Use `pnpm dev` for the frontend, `pnpm server:dev` for the API, `pnpm build` for the production frontend, and `pnpm test` for backend validation and branded-email tests. Production setup and recovery instructions are in [CUEGROVE_DEPLOY.md](CUEGROVE_DEPLOY.md). + +The PromptDock DMG endpoint is intentionally an unlisted direct download for approved Early Access recipients. Possession of that URL is not strict authorization: recipients can share it, and the server does not require an administrator session to download the current build. Use authenticated, expiring links if future releases require enforced per-user access. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2b2eaa5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,89 @@ +# CueGrove Security Policy + +[English](#reporting-a-vulnerability) · [简体中文](#报告安全问题) + +## Reporting a vulnerability + +Thank you for helping keep CueGrove, PromptDock, and their users safe. + +Please report suspected vulnerabilities privately by email: + +- **Contact:** [security@cuegroveapp.com](mailto:security@cuegroveapp.com?subject=%5BSecurity%5D%20) +- **Subject:** `[Security] Short description` +- **Scope:** PromptDock, `cuegroveapp.com`, the CueGrove Early Access API, and the administrator console + +Include the affected component and version, reproduction steps, potential impact, and only the evidence needed to understand the report. Please do not include real passwords, access tokens, or unnecessary personal information. + +### Research boundaries + +- Do not access, modify, or download data that is not yours. +- Do not perform denial-of-service, spam, social engineering, or tests that affect normal users. +- Do not use automated scanning at a volume that degrades the service. +- Keep exploitable details private until we confirm a fix or agree on a disclosure date. + +For good-faith security research that follows these boundaries, seeks to avoid harm, and is reported promptly, CueGrove will not pursue legal action based on that research alone. This statement does not authorize illegal activity, infringement of third-party rights, or service disruption. + +### What to expect + +Our response targets are: + +- acknowledgement within 5 business days; +- initial triage within 10 business days; +- status updates when there is meaningful progress or at least every 14 days during an active investigation. + +Resolution time depends on severity, complexity, and release constraints. We will coordinate disclosure timing with the reporter and credit them when requested and appropriate. + +## Supported versions + +PromptDock is currently in Early Access. Security fixes are provided in the latest available build. Once public versioned releases begin, this section will list supported versions and update channels. + +The public [CueGrove Security Commitment](https://cuegroveapp.com/security) documents current controls, known gaps, and the one-year security roadmap. + +## Supply-chain controls + +Dependency review, production dependency auditing, repository history secret +scanning, and scheduled update proposals run through GitHub. Blocking thresholds, +finding handling, and any time-limited exception are documented in +[SUPPLY_CHAIN_SECURITY.md](SUPPLY_CHAIN_SECURITY.md). + +--- + +## 报告安全问题 + +感谢你帮助保护 CueGrove、PromptDock 及其用户。 + +请通过电子邮件私下报告疑似漏洞: + +- **联系邮箱:** [security@cuegroveapp.com](mailto:security@cuegroveapp.com?subject=%5BSecurity%5D%20) +- **邮件主题:** `[Security] 简短问题描述` +- **适用范围:** PromptDock、`cuegroveapp.com`、CueGrove Early Access API 和管理后台 + +请尽量包含受影响的组件和版本、复现步骤、潜在影响及理解问题所需的证据。请勿发送真实密码、访问令牌或不必要的个人信息。 + +### 研究边界 + +- 不要访问、修改或下载不属于你的数据。 +- 不要进行拒绝服务、垃圾邮件、社会工程或影响正常用户的测试。 +- 不要以降低服务质量的频率运行自动扫描。 +- 在我们确认修复或共同约定披露时间前,请保留可利用的技术细节。 + +对于遵守上述边界、以避免伤害为目的并及时报告的善意安全研究,CueGrove 不会因为该研究本身采取法律行动。本说明不授权违法活动、侵犯第三方权益或破坏服务。 + +### 响应目标 + +- 5 个工作日内确认收到; +- 10 个工作日内完成初步分级; +- 调查期间在有实质进展时更新,或至少每 14 天提供一次状态。 + +修复时间取决于严重程度、复杂度和发布条件。我们会与报告者协调披露时间,并在报告者希望且适当的情况下公开致谢。 + +## 支持版本 + +PromptDock 目前处于 Early Access 阶段,安全修复会提供在最新可用构建中。开始公开版本化发布后,本节会列出受支持版本和更新渠道。 + +公开的 [CueGrove 安全承诺](https://cuegroveapp.com/security) 会持续说明当前保护、已知差距和一年安全路线图。 + +## 软件供应链保护 + +GitHub 会自动执行依赖变更审查、生产依赖漏洞审计、完整仓库历史凭据扫描和定期更新建议。阻断标准、问题处理方式和任何有期限的例外均记录在 +[SUPPLY_CHAIN_SECURITY.md](SUPPLY_CHAIN_SECURITY.md)。 diff --git a/SUPPLY_CHAIN_SECURITY.md b/SUPPLY_CHAIN_SECURITY.md new file mode 100644 index 0000000..ef9b115 --- /dev/null +++ b/SUPPLY_CHAIN_SECURITY.md @@ -0,0 +1,46 @@ +# CueGrove Supply-Chain Security + +This document describes the automated controls used for CueGrove website, +PromptDock, and repository dependencies. It complements [SECURITY.md](SECURITY.md). + +## Automated controls + +The `Supply chain security` GitHub Actions workflow: + +- reviews dependency changes in every pull request and blocks newly introduced + vulnerabilities rated high or critical; +- installs the exact versions in `pnpm-lock.yaml` and audits production + dependencies on pull requests, production-branch pushes, manual runs, and a + weekly schedule; +- scans the full Git history for credentials, API tokens, private keys, and + other likely secrets with Gitleaks. + +Dependabot checks the pnpm workspace and GitHub Actions each week. Its pull +requests remain subject to the same review, tests, build, audit, and secret scan +as human-authored changes. + +## Handling findings + +1. Critical findings are treated as release blockers. +2. High findings block new dependencies and production deployment unless an + exception is documented below. +3. Moderate and low findings are reviewed according to exploitability, + affected runtime path, and availability of a compatible fix. +4. Exposed credentials are revoked or rotated before the finding is marked + resolved. Removing a secret from the latest commit alone is not sufficient. + +Secret-scan false positives may only be ignored by their exact Gitleaks +fingerprint. The current ignore is the public RFC 6238 SHA-1 test vector in the +MFA test suite; broad rule or directory exclusions are not permitted. + +Reports containing exploitable detail should be sent privately to +[security@cuegroveapp.com](mailto:security@cuegroveapp.com?subject=%5BSecurity%5D%20). + +## Time-limited exceptions + +| Advisory | Affected dependency | Reason the path is not exposed | Review by | +| --- | --- | --- | --- | +| `GHSA-qwww-vcr4-c8h2` | React Router 7 | The advisory affects React Server Components action handling. CueGrove is a client-only declarative router served as static files and does not use React Router RSC or server actions. The compatible upstream fix requires the React 19, Vite 7, and React Router 8 migration, which will be handled separately. | 2026-08-29 | + +Exceptions must identify the affected path, explain why it is not reachable, +name a review date, and be removed as soon as a compatible fix is deployed. diff --git a/index.html b/index.html new file mode 100644 index 0000000..fc986bc --- /dev/null +++ b/index.html @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + CueGrove — Where ideas take root + + + +
+
+ CueGrove +
+ +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..b3ffa25 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "cuegrove-site", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "test": "node --test test/*.test.js && pnpm --dir server test", + "server:dev": "pnpm --dir server dev", + "server:start": "pnpm --dir server start" + }, + "dependencies": { + "gsap": "^3.15.0", + "lucide-react": "^0.468.0", + "ogl": "^1.0.11", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^7.1.1" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..2c7a44c --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2256 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + nanoid: 3.3.17 + +importers: + + .: + dependencies: + gsap: + specifier: ^3.15.0 + version: 3.15.0 + lucide-react: + specifier: ^0.468.0 + version: 0.468.0(react@18.3.1) + ogl: + specifier: ^1.0.11 + version: 1.0.11 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^7.1.1 + version: 7.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.18 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.5 + version: 18.3.7(@types/react@18.3.31) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.3(@types/node@26.1.1)) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vite: + specifier: ^6.0.5 + version: 6.4.3(@types/node@26.1.1) + + server: + dependencies: + bcryptjs: + specifier: ^2.4.3 + version: 2.4.3 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.22.2 + express-rate-limit: + specifier: ^7.5.0 + version: 7.5.1(express@4.22.2) + helmet: + specifier: ^8.0.0 + version: 8.3.0 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.3 + mysql2: + specifier: ^3.12.0 + version: 3.23.1(@types/node@26.1.1) + nodemailer: + specifier: ^9.0.1 + version: 9.0.3 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + + baseline-browser-mapping@2.11.1: + resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} + engines: {node: '>=6.0.0'} + hasBin: true + + bcryptjs@2.4.3: + resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + + body-parser@1.20.6: + resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.396: + resolution: {integrity: sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express-rate-limit@7.5.1: + resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@4.22.2: + resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} + engines: {node: '>= 0.10.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + gsap@3.15.0: + resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + helmet@8.3.0: + resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==} + engines: {node: '>=18.0.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + + lucide-react@0.468.0: + resolution: {integrity: sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mysql2@3.23.1: + resolution: {integrity: sha512-tTuRnC7qCet2IOfSNMYZ5SwXuBnfvBPAcIA28P0gtruXyZlU1LMxA6uha32kYypoFgyYklMqhLWwt4laYwXR/Q==} + engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + nodemailer@9.0.3: + resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} + engines: {node: '>=6.0.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + ogl@1.0.11: + resolution: {integrity: sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + + postcss@8.5.22: + resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} + engines: {node: ^10 || ^12 || >=14} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.18.1: + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.1: + resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + sql-escaper@1.5.1: + resolution: {integrity: sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + 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 + + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.7 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/estree@1.0.9': {} + + '@types/node@26.1.1': + dependencies: + undici-types: 8.3.0 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.31)': + dependencies: + '@types/react': 18.3.31 + + '@types/react@18.3.31': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@26.1.1))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.3(@types/node@26.1.1) + transitivePeerDependencies: + - supports-color + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + array-flatten@1.1.1: {} + + aws-ssl-profiles@1.1.2: {} + + baseline-browser-mapping@2.11.1: {} + + bcryptjs@2.4.3: {} + + body-parser@1.20.6: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.1 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.396 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + + buffer-equal-constant-time@1.0.1: {} + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase@5.3.1: {} + + caniuse-lite@1.0.30001806: {} + + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + + cookie-signature@1.0.6: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + csstype@3.2.3: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decamelize@1.2.0: {} + + denque@2.1.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + dijkstrajs@1.0.3: {} + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.396: {} + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.25.12: + 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 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express-rate-limit@7.5.1(express@4.22.2): + dependencies: + express: 4.22.2 + + express@4.22.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.6 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + gsap@3.15.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + helmet@8.3.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-property@1.0.2: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + jsonwebtoken@9.0.3: + 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.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + 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.1.1: {} + + long@5.3.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lru.min@1.1.4: {} + + lucide-react@0.468.0(react@18.3.1): + dependencies: + react: 18.3.1 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + mysql2@3.23.1(@types/node@26.1.1): + dependencies: + '@types/node': 26.1.1 + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.3 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + sql-escaper: 1.5.1 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + + nanoid@3.3.17: {} + + negotiator@0.6.3: {} + + node-releases@2.0.51: {} + + nodemailer@9.0.3: {} + + object-inspect@1.13.4: {} + + ogl@1.0.11: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-to-regexp@0.1.13: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pngjs@5.0.0: {} + + postcss@8.5.22: + dependencies: + nanoid: 3.3.17 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-refresh@0.17.0: {} + + react-router-dom@7.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 7.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + + react-router@7.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + cookie: 1.1.1 + react: 18.3.1 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + semver@7.8.5: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: {} + + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + sql-escaper@1.5.1: {} + + statuses@2.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + toidentifier@1.0.1: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typescript@5.9.3: {} + + undici-types@8.3.0: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + utils-merge@1.0.1: {} + + vary@1.1.2: {} + + vite@6.4.3(@types/node@26.1.1): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.22 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.1 + fsevents: 2.3.3 + + which-module@2.0.1: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@4.0.3: {} + + yallist@3.1.1: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..db319c1 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,15 @@ +packages: + - . + - server + +allowBuilds: + esbuild: true + +overrides: + nanoid: 3.3.17 + +auditConfig: + ignoreGhsas: + # CueGrove uses React Router only in declarative browser mode. The affected + # React Server Components action path is not present. Review by 2026-08-29. + - GHSA-qwww-vcr4-c8h2 diff --git a/public/.well-known/security.txt b/public/.well-known/security.txt new file mode 100644 index 0000000..d090411 --- /dev/null +++ b/public/.well-known/security.txt @@ -0,0 +1,5 @@ +Contact: mailto:security@cuegroveapp.com?subject=%5BSecurity%5D%20 +Expires: 2027-07-27T00:00:00.000Z +Preferred-Languages: en, zh +Canonical: https://cuegroveapp.com/.well-known/security.txt +Policy: https://cuegroveapp.com/security diff --git a/public/cuegrove-logo.png b/public/cuegrove-logo.png new file mode 100644 index 0000000..3be0a42 Binary files /dev/null and b/public/cuegrove-logo.png differ diff --git a/public/og.jpg b/public/og.jpg new file mode 100644 index 0000000..fe9b1d8 Binary files /dev/null and b/public/og.jpg differ diff --git a/public/og.png b/public/og.png new file mode 100644 index 0000000..698107b Binary files /dev/null and b/public/og.png differ diff --git a/public/promptdock-icon.png b/public/promptdock-icon.png new file mode 100644 index 0000000..e87f34e Binary files /dev/null and b/public/promptdock-icon.png differ diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..7da3ecf --- /dev/null +++ b/server/.env.example @@ -0,0 +1,34 @@ +NODE_ENV=development +PORT=3001 +# Number of reverse-proxy hops directly in front of Express. +# Keep 1 for Nginx. Use 2 only if Express directly receives a two-proxy X-Forwarded-For chain. +TRUST_PROXY_HOPS=1 +SITE_URL=http://localhost:5173 +ALLOWED_ORIGINS=http://localhost:5173 + +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_USER=cuegrove +DB_PASSWORD=replace_with_a_database_password +DB_NAME=cuegrove +DB_CONNECTION_LIMIT=10 + +JWT_SECRET=replace_with_at_least_32_random_characters +TURNSTILE_SECRET= +COOKIE_NAME=cuegrove_admin +COOKIE_SECURE=false +COOKIE_DOMAIN= + +MAIL_ENABLED=false +SMTP_HOST= +SMTP_PORT=465 +SMTP_SECURE=true +SMTP_AUTH_REQUIRED=true +SMTP_TIMEOUT_MS=20000 +SMTP_HELO_NAME= +SMTP_USER= +SMTP_PASS= +SMTP_FROM=CueGrove +SMTP_REPLY_TO= + +PASSWORD_RESET_MINUTES=30 diff --git a/server/database/migrations/001_initial.sql b/server/database/migrations/001_initial.sql new file mode 100644 index 0000000..4073667 --- /dev/null +++ b/server/database/migrations/001_initial.sql @@ -0,0 +1,98 @@ +CREATE TABLE IF NOT EXISTS admin_users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(80) NOT NULL, + email VARCHAR(160) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role ENUM('owner', 'admin') NOT NULL DEFAULT 'admin', + status ENUM('active', 'disabled') NOT NULL DEFAULT 'active', + failed_login_count INT UNSIGNED NOT NULL DEFAULT 0, + locked_until DATETIME NULL, + last_login_at DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_admin_users_email (email), + KEY idx_admin_users_status (status), + KEY idx_admin_users_locked_until (locked_until) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_password_resets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + admin_id BIGINT UNSIGNED NOT NULL, + token_hash CHAR(64) NOT NULL, + expires_at DATETIME NOT NULL, + used_at DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_admin_password_resets_token (token_hash), + KEY idx_admin_password_resets_admin (admin_id), + KEY idx_admin_password_resets_expiry (expires_at), + CONSTRAINT fk_admin_password_resets_admin + FOREIGN KEY (admin_id) REFERENCES admin_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS early_access_applications ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + full_name VARCHAR(80) NOT NULL, + email VARCHAR(160) NOT NULL, + role ENUM('student', 'creator', 'developer', 'researcher', 'product', 'other') NOT NULL, + use_case TEXT NOT NULL, + motivation TEXT NOT NULL, + locale ENUM('zh', 'en') NOT NULL DEFAULT 'zh', + cohort VARCHAR(80) NOT NULL, + status ENUM('pending', 'approved', 'rejected') NOT NULL DEFAULT 'pending', + reviewer_id BIGINT UNSIGNED NULL, + internal_note TEXT NULL, + applicant_message TEXT NULL, + consented_at DATETIME NOT NULL, + reviewed_at DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_early_access_email_cohort (email, cohort), + KEY idx_early_access_status_created (status, created_at), + KEY idx_early_access_reviewer (reviewer_id), + CONSTRAINT fk_early_access_reviewer + FOREIGN KEY (reviewer_id) REFERENCES admin_users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS mail_delivery_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + application_id BIGINT UNSIGNED NULL, + mail_kind ENUM('new_application', 'approved', 'rejected', 'password_reset', 'test') NOT NULL, + locale ENUM('zh', 'en') NOT NULL DEFAULT 'en', + recipient VARCHAR(160) NOT NULL, + subject VARCHAR(255) NOT NULL, + status ENUM('sent', 'failed') NOT NULL, + attempt_number INT UNSIGNED NOT NULL DEFAULT 1, + error_message VARCHAR(500) NULL, + sent_at DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_mail_application_kind (application_id, mail_kind, id), + KEY idx_mail_status_created (status, created_at), + KEY idx_mail_recipient_created (recipient, created_at), + CONSTRAINT fk_mail_application + FOREIGN KEY (application_id) REFERENCES early_access_applications(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS site_settings ( + setting_key VARCHAR(100) NOT NULL, + setting_value JSON NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (setting_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO site_settings (setting_key, setting_value) +VALUES ( + 'early_access', + JSON_OBJECT( + 'applications_open', TRUE, + 'cohort', 'promptdock-early-access-1', + 'download_url', '', + 'feedback_url', '', + 'notify_email', '', + 'notify_on_new_application', FALSE + ) +) +ON DUPLICATE KEY UPDATE setting_key=VALUES(setting_key); diff --git a/server/database/migrations/002_admin_console_settings.sql b/server/database/migrations/002_admin_console_settings.sql new file mode 100644 index 0000000..33fdadb --- /dev/null +++ b/server/database/migrations/002_admin_console_settings.sql @@ -0,0 +1,19 @@ +INSERT INTO site_settings (setting_key, setting_value) +VALUES ( + 'mail_transport', + JSON_OBJECT( + 'configured', FALSE, + 'enabled', FALSE, + 'host', '', + 'port', 587, + 'secure', FALSE, + 'auth_required', TRUE, + 'timeout_ms', 20000, + 'helo_name', '', + 'user', '', + 'password_encrypted', '', + 'from', '', + 'reply_to', '' + ) +) +ON DUPLICATE KEY UPDATE setting_key=VALUES(setting_key); diff --git a/server/database/migrations/003_admin_mfa.sql b/server/database/migrations/003_admin_mfa.sql new file mode 100644 index 0000000..85fabbe --- /dev/null +++ b/server/database/migrations/003_admin_mfa.sql @@ -0,0 +1,6 @@ +ALTER TABLE admin_users + ADD COLUMN mfa_secret_encrypted TEXT NULL, + ADD COLUMN mfa_setup_expires_at DATETIME NULL, + ADD COLUMN mfa_enabled_at DATETIME NULL, + ADD COLUMN mfa_recovery_hashes JSON NULL, + ADD COLUMN mfa_last_used_step BIGINT UNSIGNED NULL; diff --git a/server/database/migrations/004_feedback.sql b/server/database/migrations/004_feedback.sql new file mode 100644 index 0000000..cbfa360 --- /dev/null +++ b/server/database/migrations/004_feedback.sql @@ -0,0 +1,35 @@ +CREATE TABLE IF NOT EXISTS feedback_access_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + application_id BIGINT UNSIGNED NOT NULL, + token_hash CHAR(64) NOT NULL, + expires_at DATETIME NULL, + revoked_at DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_feedback_token_application (application_id), + UNIQUE KEY uq_feedback_token_hash (token_hash), + CONSTRAINT fk_feedback_token_application + FOREIGN KEY (application_id) REFERENCES early_access_applications(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS feedback_reports ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + application_id BIGINT UNSIGNED NOT NULL, + category ENUM('bug', 'idea', 'ux', 'performance', 'other') NOT NULL, + title VARCHAR(160) NOT NULL, + details TEXT NOT NULL, + steps TEXT NULL, + expected TEXT NULL, + actual TEXT NULL, + device VARCHAR(120) NULL, + macos_version VARCHAR(80) NULL, + app_build VARCHAR(80) NULL, + status ENUM('new', 'triaged', 'resolved') NOT NULL DEFAULT 'new', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_feedback_reports_created (created_at), + KEY idx_feedback_reports_status (status), + CONSTRAINT fk_feedback_report_application + FOREIGN KEY (application_id) REFERENCES early_access_applications(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server/database/migrations/005_feedback_conversations.sql b/server/database/migrations/005_feedback_conversations.sql new file mode 100644 index 0000000..59d1973 --- /dev/null +++ b/server/database/migrations/005_feedback_conversations.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS feedback_messages ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + report_id BIGINT UNSIGNED NOT NULL, + author_type ENUM('tester', 'developer') NOT NULL, + admin_id BIGINT UNSIGNED NULL, + body TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_feedback_messages_report_created (report_id, created_at, id), + CONSTRAINT fk_feedback_message_report + FOREIGN KEY (report_id) REFERENCES feedback_reports(id) ON DELETE CASCADE, + CONSTRAINT fk_feedback_message_admin + FOREIGN KEY (admin_id) REFERENCES admin_users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE feedback_access_tokens + ADD KEY idx_feedback_token_application (application_id), + DROP INDEX uq_feedback_token_application; + +ALTER TABLE mail_delivery_logs + MODIFY mail_kind ENUM( + 'new_application', + 'approved', + 'rejected', + 'password_reset', + 'test', + 'feedback_to_owner', + 'feedback_to_tester' + ) NOT NULL; diff --git a/server/database/migrations/006_roll_access.sql b/server/database/migrations/006_roll_access.sql new file mode 100644 index 0000000..11f19b6 --- /dev/null +++ b/server/database/migrations/006_roll_access.sql @@ -0,0 +1,20 @@ +ALTER TABLE admin_users + ADD COLUMN can_issue_roll_keys TINYINT(1) NOT NULL DEFAULT 0 AFTER status; + +CREATE TABLE IF NOT EXISTS roll_access_keys ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + key_hash CHAR(64) NOT NULL, + key_prefix VARCHAR(12) NOT NULL, + created_by BIGINT UNSIGNED NULL, + expires_at DATETIME NOT NULL, + revoked_at DATETIME NULL, + last_used_at DATETIME NULL, + use_count INT UNSIGNED NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_roll_access_keys_hash (key_hash), + KEY idx_roll_access_keys_expiry (expires_at), + KEY idx_roll_access_keys_created (created_at), + CONSTRAINT fk_roll_access_keys_admin + FOREIGN KEY (created_by) REFERENCES admin_users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server/database/migrations/007_roll_access_audit.sql b/server/database/migrations/007_roll_access_audit.sql new file mode 100644 index 0000000..e4847ac --- /dev/null +++ b/server/database/migrations/007_roll_access_audit.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS roll_access_key_audit ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + key_id BIGINT UNSIGNED NOT NULL, + key_prefix VARCHAR(12) NOT NULL, + admin_id BIGINT UNSIGNED NULL, + action ENUM('create', 'revoke', 'delete', 'bulk_delete') NOT NULL, + batch_id CHAR(36) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_roll_access_audit_key (key_id), + KEY idx_roll_access_audit_admin (admin_id), + KEY idx_roll_access_audit_created (created_at), + KEY idx_roll_access_audit_batch (batch_id), + CONSTRAINT fk_roll_access_audit_admin + FOREIGN KEY (admin_id) REFERENCES admin_users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server/ecosystem.config.cjs b/server/ecosystem.config.cjs new file mode 100644 index 0000000..2b80ba5 --- /dev/null +++ b/server/ecosystem.config.cjs @@ -0,0 +1,14 @@ +module.exports = { + apps: [{ + name: 'cuegrove-api', + script: 'src/index.js', + cwd: __dirname, + instances: 1, + exec_mode: 'fork', + autorestart: true, + max_memory_restart: '350M', + env: { + NODE_ENV: 'production', + }, + }], +}; diff --git a/server/nginx.cuegrove.conf.example b/server/nginx.cuegrove.conf.example new file mode 100644 index 0000000..5215895 --- /dev/null +++ b/server/nginx.cuegrove.conf.example @@ -0,0 +1,44 @@ +server { + listen 80; + server_name cuegrove.example www.cuegrove.example; + + root /www/wwwroot/cuegrove/dist; + index index.html; + + add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + + location /api/ { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 64k; + } + + location / { + try_files $uri $uri/ /index.html; + } + + location /assets/ { + expires 1y; + try_files $uri =404; + } + + location ~* \.(png|jpe?g|webp|ico)$ { + expires 7d; + try_files $uri =404; + } + + location ~ /\.(?!well-known) { + deny all; + } + + # Add on the HTTPS server only: + # add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..6a8acb2 --- /dev/null +++ b/server/package.json @@ -0,0 +1,26 @@ +{ + "name": "cuegrove-server", + "version": "1.0.0", + "private": true, + "main": "src/index.js", + "scripts": { + "dev": "node --watch src/index.js", + "start": "node src/index.js", + "migrate": "node src/scripts/migrate.js", + "create-admin": "node src/scripts/create-admin.js", + "reset-mfa": "node src/scripts/reset-mfa.js", + "test": "node --test test/*.test.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.7", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "express-rate-limit": "^7.5.0", + "helmet": "^8.0.0", + "jsonwebtoken": "^9.0.2", + "mysql2": "^3.12.0", + "nodemailer": "^9.0.1", + "qrcode": "^1.5.4" + } +} diff --git a/server/src/app.js b/server/src/app.js new file mode 100644 index 0000000..c752684 --- /dev/null +++ b/server/src/app.js @@ -0,0 +1,61 @@ +const express = require('express'); +const cookieParser = require('cookie-parser'); +const helmet = require('helmet'); +const authRoutes = require('./routes/auth'); +const earlyAccessRoutes = require('./routes/earlyAccess'); +const adminRoutes = require('./routes/admin'); +const feedbackRoutes = require('./routes/feedback'); +const rollAccessRoutes = require('./routes/rollAccess'); +const releaseRoutes = require('./routes/releases'); +const { requireSameOrigin } = require('./middleware/security'); +const config = require('./config'); + +function createApp() { + const app = express(); + app.set('trust proxy', config.trustProxyHops); + app.use(helmet({ + crossOriginResourcePolicy: { policy: 'same-site' }, + contentSecurityPolicy: { + useDefaults: false, + directives: { + defaultSrc: ["'none'"], + baseUri: ["'none'"], + formAction: ["'none'"], + frameAncestors: ["'none'"], + }, + }, + })); + app.use(express.json({ limit: '32kb' })); + app.use(cookieParser()); + app.use('/api', requireSameOrigin); + app.get('/api/health', (_req, res) => res.json({ ok: true, service: 'cuegrove' })); + app.use('/api/auth', authRoutes); + app.use('/api/early-access', earlyAccessRoutes); + app.use('/api/feedback', feedbackRoutes.router); + app.use('/api/roll-access', rollAccessRoutes.publicRouter); + app.use('/api/releases', releaseRoutes); + app.use('/api/admin/feedback', feedbackRoutes.adminRouter); + app.use('/api/admin/roll-access-keys', rollAccessRoutes.adminRouter); + app.use('/api/admin', adminRoutes); + + app.use((req, res) => { + res.status(404).json({ message: 'API route not found.' }); + }); + + app.use((error, _req, res, _next) => { + console.error('[server]', error); + if (error.type === 'entity.too.large') { + return res.status(413).json({ message: 'The uploaded file is too large.' }); + } + if (error.type === 'entity.parse.failed') { + return res.status(400).json({ message: 'Request body must contain valid JSON.' }); + } + if (['ER_NO_SUCH_TABLE', 'ER_BAD_DB_ERROR', 'ER_BAD_FIELD_ERROR'].includes(error.code)) { + return res.status(503).json({ message: 'Database is not ready. Run the CueGrove migration.' }); + } + return res.status(500).json({ message: 'The server could not complete this request.' }); + }); + return app; +} + +module.exports = { createApp }; diff --git a/server/src/config.js b/server/src/config.js new file mode 100644 index 0000000..60f77a0 --- /dev/null +++ b/server/src/config.js @@ -0,0 +1,66 @@ +const path = require('path'); +const dotenv = require('dotenv'); + +dotenv.config({ path: path.resolve(__dirname, '..', '.env') }); + +function bool(value, fallback = false) { + if (value === undefined || value === null || value === '') return fallback; + return String(value).toLowerCase() === 'true'; +} + +function int(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function cleanOrigin(value) { + try { + return new URL(value).origin; + } catch { + return ''; + } +} + +const siteUrl = process.env.SITE_URL || 'http://localhost:5173'; +const allowedOrigins = new Set( + [siteUrl, ...(process.env.ALLOWED_ORIGINS || '').split(',')] + .map((value) => cleanOrigin(value.trim())) + .filter(Boolean), +); + +module.exports = { + env: process.env.NODE_ENV || 'development', + port: int(process.env.PORT, 3001), + trustProxyHops: int(process.env.TRUST_PROXY_HOPS, 1), + siteUrl: cleanOrigin(siteUrl) || 'http://localhost:5173', + allowedOrigins, + db: { + host: process.env.DB_HOST || '127.0.0.1', + port: int(process.env.DB_PORT, 3306), + user: process.env.DB_USER || '', + password: process.env.DB_PASSWORD || '', + database: process.env.DB_NAME || '', + connectionLimit: int(process.env.DB_CONNECTION_LIMIT, 10), + }, + jwtSecret: process.env.JWT_SECRET || '', + turnstileSecret: process.env.TURNSTILE_SECRET || '', + cookie: { + name: process.env.COOKIE_NAME || 'cuegrove_admin', + secure: bool(process.env.COOKIE_SECURE, process.env.NODE_ENV === 'production'), + domain: (process.env.COOKIE_DOMAIN || '').trim(), + }, + mail: { + enabled: bool(process.env.MAIL_ENABLED), + host: process.env.SMTP_HOST || '', + port: int(process.env.SMTP_PORT, 465), + secure: bool(process.env.SMTP_SECURE, true), + authRequired: bool(process.env.SMTP_AUTH_REQUIRED, true), + timeoutMs: int(process.env.SMTP_TIMEOUT_MS, 20000), + heloName: (process.env.SMTP_HELO_NAME || '').trim(), + user: process.env.SMTP_USER || '', + pass: process.env.SMTP_PASS || '', + from: process.env.SMTP_FROM || process.env.SMTP_USER || '', + replyTo: process.env.SMTP_REPLY_TO || '', + }, + passwordResetMinutes: int(process.env.PASSWORD_RESET_MINUTES, 30), +}; diff --git a/server/src/db.js b/server/src/db.js new file mode 100644 index 0000000..1dff4d9 --- /dev/null +++ b/server/src/db.js @@ -0,0 +1,12 @@ +const mysql = require('mysql2/promise'); +const config = require('./config'); + +const pool = mysql.createPool({ + ...config.db, + waitForConnections: true, + queueLimit: 0, + charset: 'utf8mb4', + timezone: 'Z', +}); + +module.exports = pool; diff --git a/server/src/index.js b/server/src/index.js new file mode 100644 index 0000000..455907b --- /dev/null +++ b/server/src/index.js @@ -0,0 +1,28 @@ +const config = require('./config'); +const db = require('./db'); +const { createApp } = require('./app'); + +async function start() { + if (!config.db.database || !config.db.user) { + throw new Error('DB_NAME and DB_USER must be configured.'); + } + await db.query('SELECT 1'); + const app = createApp(); + const server = app.listen(config.port, '127.0.0.1', () => { + console.log(`[cuegrove] API listening on http://127.0.0.1:${config.port}`); + }); + + const shutdown = async () => { + server.close(async () => { + await db.end(); + process.exit(0); + }); + }; + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); +} + +start().catch((error) => { + console.error('[cuegrove] Failed to start:', error.message); + process.exit(1); +}); diff --git a/server/src/lib/mailer.js b/server/src/lib/mailer.js new file mode 100644 index 0000000..f573a77 --- /dev/null +++ b/server/src/lib/mailer.js @@ -0,0 +1,352 @@ +const nodemailer = require('nodemailer'); +const db = require('../db'); +const config = require('../config'); +const { getSetting } = require('./settings'); +const { decryptSecret } = require('./secrets'); +const { string } = require('./validation'); + +function escapeHtml(value) { + return String(value == null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function cleanHeader(value) { + return String(value || '').replace(/[\r\n]+/g, ' ').trim(); +} + +function mailReady(mailConfig = config.mail) { + return Boolean( + mailConfig.enabled && + mailConfig.host && + mailConfig.from && + ( + !mailConfig.authRequired || + (mailConfig.user && mailConfig.pass) + ), + ); +} + +function smtpTransportOptions(mailConfig = config.mail) { + const options = { + host: mailConfig.host, + port: mailConfig.port, + secure: mailConfig.secure, + connectionTimeout: mailConfig.timeoutMs, + greetingTimeout: mailConfig.timeoutMs, + socketTimeout: mailConfig.timeoutMs, + }; + if (mailConfig.heloName) options.name = mailConfig.heloName; + if (mailConfig.authRequired) { + options.auth = { + user: mailConfig.user, + pass: mailConfig.pass, + }; + } + return options; +} + +async function resolveMailConfig() { + const stored = await getSetting('mail_transport'); + if (!stored.configured) return { ...config.mail, source: 'environment' }; + let password = ''; + let configurationError = ''; + try { + password = decryptSecret(stored.password_encrypted); + } catch (error) { + configurationError = error.message; + } + return { + enabled: stored.enabled === true, + host: string(stored.host, 255), + port: Number(stored.port) || 587, + secure: stored.secure === true, + authRequired: stored.auth_required === true, + timeoutMs: Number(stored.timeout_ms) || 20000, + heloName: string(stored.helo_name, 255), + user: string(stored.user, 255), + pass: password, + from: string(stored.from, 255), + replyTo: string(stored.reply_to, 255), + source: 'database', + configurationError, + }; +} + +function transporter(mailConfig) { + if (!mailReady(mailConfig)) { + throw new Error('SMTP is disabled or incomplete.'); + } + return nodemailer.createTransport(smtpTransportOptions(mailConfig)); +} + +function emailFrame({ locale, title, bodyHtml, actionLabel, actionUrl, footer }) { + const preheader = locale === 'zh' + ? '来自 CueGrove 的 PromptDock Early Access 消息' + : 'A PromptDock Early Access message from CueGrove'; + const action = actionUrl + ? `

${escapeHtml(actionLabel)}

` + : ''; + + return ` + + + +
${escapeHtml(preheader)}
+ + +
+ + + + +
+ CueGrove +
+
+

${escapeHtml(title)}

+
${bodyHtml}
+ ${action} +
${escapeHtml(footer)}
+
CueGrove · Calm, dependable tools that respect your privacy.
+
+ +`; +} + +function buildDecisionMessage(application, settings) { + const approved = application.status === 'approved'; + const zh = application.locale === 'zh'; + const applicantMessage = string(application.applicant_message, 1500); + + const subject = approved + ? (zh ? '[CueGrove] PromptDock Early Access 申请已通过' : '[CueGrove] You’re in — PromptDock Early Access') + : (zh ? '[CueGrove] PromptDock Early Access 申请结果' : '[CueGrove] Your PromptDock Early Access application'); + + const title = approved + ? (zh ? '欢迎加入 PromptDock Early Access' : 'Welcome to PromptDock Early Access') + : (zh ? '感谢你的 Early Access 申请' : 'Thank you for applying'); + + const intro = approved + ? (zh + ? `${application.full_name},你好!你的 PromptDock Early Access 申请已经通过。` + : `Hi ${application.full_name}, your PromptDock Early Access application has been approved.`) + : (zh + ? `${application.full_name},你好!感谢你认真填写 PromptDock Early Access 申请。经过本轮评估,我们暂时无法为你开放测试资格。` + : `Hi ${application.full_name}, thank you for your thoughtful PromptDock Early Access application. We cannot offer a place in this round.`); + + const guidance = approved + ? (zh + ? '请通过下方按钮下载测试版本。Early Access 软件仍在持续打磨,请先备份重要内容,并通过反馈入口告诉我们你的真实体验。' + : 'Use the button below to download the preview. Early Access software is still evolving, so please keep backups and share your experience through the feedback link.') + : (zh + ? '这并不代表你的想法不重要,只是当前测试名额和场景有限。我们会保留本轮记录,并在未来更合适的机会出现时继续与你联系。' + : 'This does not diminish your ideas; this round simply has limited places and testing needs. We will keep this application on record for future opportunities.'); + + const customHtml = applicantMessage + ? `
${zh ? '给你的补充说明' : 'A note for you'}
${escapeHtml(applicantMessage).replace(/\n/g, '
')}
` + : ''; + + const feedbackHtml = approved && settings.feedback_url + ? `

${zh ? '反馈入口' : 'Feedback'}:${escapeHtml(settings.feedback_url)}

` + : ''; + + const html = emailFrame({ + locale: application.locale, + title, + bodyHtml: `

${escapeHtml(intro)}

${escapeHtml(guidance)}

${customHtml}${feedbackHtml}`, + actionLabel: approved ? (zh ? '下载 PromptDock' : 'Download PromptDock') : '', + actionUrl: approved ? settings.download_url : '', + footer: zh + ? '这封邮件仅与你的 PromptDock Early Access 申请有关。CueGrove 不会出售你的申请信息。' + : 'This email relates only to your PromptDock Early Access application. CueGrove never sells your application data.', + }); + + const text = [ + intro, + '', + guidance, + applicantMessage ? `\n${zh ? '补充说明' : 'Note'}:\n${applicantMessage}` : '', + approved ? `\n${zh ? '下载' : 'Download'}: ${settings.download_url}` : '', + approved && settings.feedback_url ? `${zh ? '反馈' : 'Feedback'}: ${settings.feedback_url}` : '', + ].filter(Boolean).join('\n'); + + return { subject, title, html, text, kind: approved ? 'approved' : 'rejected' }; +} + +function buildFeedbackNotification({ actor, locale, recipientName, reportTitle, actionUrl, isNewReport = false }) { + const zh = locale === 'zh'; + const fromTester = actor === 'tester'; + const subject = fromTester + ? (zh ? `[CueGrove] PromptDock ${isNewReport ? '收到新反馈' : '测试用户回复了反馈'}` : `[CueGrove] PromptDock ${isNewReport ? 'received new feedback' : 'tester replied'}`) + : (zh ? '[CueGrove] PromptDock 开发者回复了你的反馈' : '[CueGrove] PromptDock developer replied'); + const title = fromTester + ? (zh ? (isNewReport ? '收到一条新的测试反馈' : '测试用户发来了回复') : (isNewReport ? 'New tester feedback' : 'A tester replied')) + : (zh ? '开发者回复了你的反馈' : 'The developer replied'); + const greeting = recipientName ? `${zh ? '你好' : 'Hi'} ${recipientName}${zh ? ',' : ', '}` : ''; + const body = fromTester + ? (zh ? `${greeting}反馈会话“${reportTitle}”有新内容,请进入后台查看并回复。` : `${greeting}The feedback conversation “${reportTitle}” has new activity. Open the dashboard to review and reply.`) + : (zh ? `${greeting}PromptDock 开发者回复了反馈“${reportTitle}”。打开反馈页面即可查看并继续回复。` : `${greeting}The PromptDock developer replied to “${reportTitle}”. Open the feedback page to read and continue the conversation.`); + const actionLabel = fromTester ? (zh ? '查看并回复' : 'Review and reply') : (zh ? '查看开发者回复' : 'View developer reply'); + const html = emailFrame({ + locale, + title, + bodyHtml: `

${escapeHtml(body)}

`, + actionLabel, + actionUrl, + footer: zh + ? '为保护隐私,反馈正文不会显示在通知邮件中。' + : 'To protect privacy, the feedback content is not included in this notification email.', + }); + return { subject, html, text: `${body}\n\n${actionUrl}` }; +} + +async function sendRaw({ to, subject, text, html }) { + const mailConfig = await resolveMailConfig(); + const mailer = transporter(mailConfig); + await mailer.sendMail({ + from: mailConfig.from, + replyTo: mailConfig.replyTo || undefined, + to, + subject: cleanHeader(subject), + text, + html, + }); +} + +async function nextAttempt(applicationId, kind) { + const [rows] = await db.query( + 'SELECT COALESCE(MAX(attempt_number), 0) + 1 AS next_attempt FROM mail_delivery_logs WHERE application_id <=> ? AND mail_kind=?', + [applicationId || null, kind], + ); + return Number(rows[0]?.next_attempt || 1); +} + +async function deliverLogged({ applicationId = null, kind, locale, to, subject, text, html }) { + const attempt = await nextAttempt(applicationId, kind); + try { + await sendRaw({ to, subject, text, html }); + await db.query( + `INSERT INTO mail_delivery_logs + (application_id, mail_kind, locale, recipient, subject, status, attempt_number, sent_at) + VALUES (?, ?, ?, ?, ?, 'sent', ?, NOW())`, + [applicationId, kind, locale, to, cleanHeader(subject), attempt], + ); + return { status: 'sent' }; + } catch (error) { + const reason = string(error.message || 'Mail delivery failed.', 500); + await db.query( + `INSERT INTO mail_delivery_logs + (application_id, mail_kind, locale, recipient, subject, status, attempt_number, error_message) + VALUES (?, ?, ?, ?, ?, 'failed', ?, ?)`, + [applicationId, kind, locale, to, cleanHeader(subject), attempt, reason], + ); + return { status: 'failed', error: reason }; + } +} + +async function sendDecisionEmail(application, settings) { + const message = buildDecisionMessage(application, settings); + return deliverLogged({ + applicationId: application.id, + kind: message.kind, + locale: application.locale, + to: application.email, + ...message, + }); +} + +async function sendFeedbackNotification({ applicationId, to, actor, locale, recipientName, reportTitle, actionUrl, isNewReport }) { + const message = buildFeedbackNotification({ actor, locale, recipientName, reportTitle, actionUrl, isNewReport }); + return deliverLogged({ + applicationId, + kind: actor === 'tester' ? 'feedback_to_owner' : 'feedback_to_tester', + locale, + to, + ...message, + }); +} + +async function sendNewApplicationNotification(application, settings) { + if (!settings.notify_on_new_application || !settings.notify_email) return { status: 'skipped' }; + const reviewUrl = `${config.siteUrl}/admin/early-access`; + const subject = `[CueGrove] New PromptDock Early Access application — ${cleanHeader(application.full_name)}`; + const text = `A new application is ready for review.\n\nName: ${application.full_name}\nEmail: ${application.email}\nRole: ${application.role}\n\nReview: ${reviewUrl}`; + const html = emailFrame({ + locale: 'en', + title: 'A new application is ready', + bodyHtml: `

Name: ${escapeHtml(application.full_name)}

Email: ${escapeHtml(application.email)}

Role: ${escapeHtml(application.role)}

`, + actionLabel: 'Review application', + actionUrl: reviewUrl, + footer: 'This is an administrator notification from CueGrove.', + }); + return deliverLogged({ + applicationId: application.id, + kind: 'new_application', + locale: 'en', + to: settings.notify_email, + subject, + text, + html, + }); +} + +function buildPasswordResetUrl(token) { + return `${config.siteUrl}/admin/reset-password#token=${encodeURIComponent(token)}`; +} + +async function sendPasswordReset(admin, token) { + const resetUrl = buildPasswordResetUrl(token); + const subject = '[CueGrove] Reset your administrator password'; + const text = `Use this link to reset your CueGrove administrator password. It expires in ${config.passwordResetMinutes} minutes.\n\n${resetUrl}`; + const html = emailFrame({ + locale: 'en', + title: 'Reset your administrator password', + bodyHtml: `

Hi ${escapeHtml(admin.name)}, use the button below to choose a new password. The link expires in ${config.passwordResetMinutes} minutes.

`, + actionLabel: 'Reset password', + actionUrl: resetUrl, + footer: 'If you did not request this, you can safely ignore this email.', + }); + return deliverLogged({ + kind: 'password_reset', + locale: 'en', + to: admin.email, + subject, + text, + html, + }); +} + +async function sendTestEmail(to) { + const subject = '[CueGrove] SMTP test'; + const text = 'Your CueGrove SMTP configuration is working.'; + const html = emailFrame({ + locale: 'en', + title: 'SMTP is ready', + bodyHtml: '

Your CueGrove server can send branded transactional email.

', + actionLabel: '', + actionUrl: '', + footer: 'Sent from CueGrove administrator settings.', + }); + return deliverLogged({ kind: 'test', locale: 'en', to, subject, text, html }); +} + +module.exports = { + buildFeedbackNotification, + buildPasswordResetUrl, + buildDecisionMessage, + cleanHeader, + emailFrame, + escapeHtml, + mailReady, + resolveMailConfig, + smtpTransportOptions, + sendDecisionEmail, + sendFeedbackNotification, + sendNewApplicationNotification, + sendPasswordReset, + sendTestEmail, +}; diff --git a/server/src/lib/mfa.js b/server/src/lib/mfa.js new file mode 100644 index 0000000..4e7561a --- /dev/null +++ b/server/src/lib/mfa.js @@ -0,0 +1,149 @@ +const crypto = require('crypto'); + +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +const RECOVERY_CODE_GROUPS = 5; + +function base32Encode(value) { + const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value); + let bits = 0; + let buffer = 0; + let output = ''; + + for (const byte of bytes) { + buffer = (buffer << 8) | byte; + bits += 8; + while (bits >= 5) { + output += BASE32_ALPHABET[(buffer >>> (bits - 5)) & 31]; + bits -= 5; + } + } + + if (bits > 0) output += BASE32_ALPHABET[(buffer << (5 - bits)) & 31]; + return output; +} + +function base32Decode(value) { + const input = String(value || '').toUpperCase().replace(/=+$/g, '').replace(/\s+/g, ''); + let bits = 0; + let buffer = 0; + const output = []; + + for (const character of input) { + const index = BASE32_ALPHABET.indexOf(character); + if (index < 0) throw new Error('TOTP secret contains invalid Base32 characters.'); + buffer = (buffer << 5) | index; + bits += 5; + if (bits >= 8) { + output.push((buffer >>> (bits - 8)) & 255); + bits -= 8; + } + } + + return Buffer.from(output); +} + +function generateTotpSecret(bytes = 20) { + return base32Encode(crypto.randomBytes(bytes)); +} + +function hotp(secret, counter, { digits = 6, algorithm = 'sha1' } = {}) { + const counterBuffer = Buffer.alloc(8); + counterBuffer.writeBigUInt64BE(BigInt(counter)); + const digest = crypto.createHmac(algorithm, base32Decode(secret)).update(counterBuffer).digest(); + const offset = digest[digest.length - 1] & 15; + const binary = ( + ((digest[offset] & 127) << 24) + | ((digest[offset + 1] & 255) << 16) + | ((digest[offset + 2] & 255) << 8) + | (digest[offset + 3] & 255) + ); + return String(binary % (10 ** digits)).padStart(digits, '0'); +} + +function totpAt(secret, timeMs = Date.now(), options = {}) { + const period = options.period || 30; + const step = Math.floor(timeMs / 1000 / period); + return hotp(secret, step, options); +} + +function safeCodeEqual(expected, received) { + const left = Buffer.from(String(expected)); + const right = Buffer.from(String(received)); + return left.length === right.length && crypto.timingSafeEqual(left, right); +} + +function matchingTotpStep(secret, token, { + timeMs = Date.now(), + window = 1, + period = 30, + digits = 6, + algorithm = 'sha1', + lastUsedStep = null, +} = {}) { + const normalized = String(token || '').replace(/\s+/g, ''); + if (!new RegExp(`^\\d{${digits}}$`).test(normalized)) return null; + const currentStep = Math.floor(timeMs / 1000 / period); + for (let offset = -window; offset <= window; offset += 1) { + const step = currentStep + offset; + if (lastUsedStep !== null && step <= Number(lastUsedStep)) continue; + if (safeCodeEqual(hotp(secret, step, { digits, algorithm }), normalized)) return step; + } + return null; +} + +function buildOtpAuthUri({ secret, email, issuer = 'CueGrove' }) { + const label = encodeURIComponent(`${issuer}:${email}`); + const parameters = new URLSearchParams({ + secret, + issuer, + algorithm: 'SHA1', + digits: '6', + period: '30', + }); + return `otpauth://totp/${label}?${parameters.toString()}`; +} + +function normalizeRecoveryCode(value) { + return String(value || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); +} + +function generateRecoveryCodes(count = 10) { + return Array.from({ length: count }, () => { + const compact = crypto.randomBytes(10).toString('hex').toUpperCase(); + return compact.match(new RegExp(`.{1,4}`, 'g')).slice(0, RECOVERY_CODE_GROUPS).join('-'); + }); +} + +function hashRecoveryCode(value) { + return crypto.createHash('sha256').update(normalizeRecoveryCode(value)).digest('hex'); +} + +function parseRecoveryHashes(value) { + if (!value) return []; + if (Array.isArray(value)) return value.filter((item) => typeof item === 'string'); + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []; + } catch { + return []; + } +} + +function findRecoveryCodeIndex(hashes, value) { + const candidate = hashRecoveryCode(value); + return hashes.findIndex((stored) => safeCodeEqual(stored, candidate)); +} + +module.exports = { + base32Decode, + base32Encode, + buildOtpAuthUri, + findRecoveryCodeIndex, + generateRecoveryCodes, + generateTotpSecret, + hashRecoveryCode, + matchingTotpStep, + normalizeRecoveryCode, + parseRecoveryHashes, + totpAt, +}; diff --git a/server/src/lib/releaseStorage.js b/server/src/lib/releaseStorage.js new file mode 100644 index 0000000..aabfd9b --- /dev/null +++ b/server/src/lib/releaseStorage.js @@ -0,0 +1,55 @@ +const crypto = require('crypto'); +const fs = require('fs/promises'); +const path = require('path'); + +const RELEASE_FILE_NAME = 'PromptDock.dmg'; +const DEFAULT_RELEASE_DIRECTORY = path.resolve(__dirname, '..', '..', 'uploads', 'releases'); + +function isDmg(buffer) { + return Buffer.isBuffer(buffer) + && buffer.length >= 512 + && buffer.subarray(buffer.length - 512, buffer.length - 508).equals(Buffer.from('koly')); +} + +function releaseFilePath(directory = DEFAULT_RELEASE_DIRECTORY) { + return path.join(directory, RELEASE_FILE_NAME); +} + +async function saveRelease(buffer, directory = DEFAULT_RELEASE_DIRECTORY) { + if (!isDmg(buffer)) throw new Error('The uploaded file is not a valid DMG image.'); + await fs.mkdir(directory, { recursive: true }); + const temporaryPath = path.join(directory, `.${RELEASE_FILE_NAME}.${crypto.randomUUID()}.tmp`); + try { + await fs.writeFile(temporaryPath, buffer, { flag: 'wx', mode: 0o600 }); + await fs.rename(temporaryPath, releaseFilePath(directory)); + } catch (error) { + await fs.unlink(temporaryPath).catch(() => undefined); + throw error; + } + return releaseInfo(directory); +} + +async function releaseInfo(directory = DEFAULT_RELEASE_DIRECTORY) { + try { + const stat = await fs.stat(releaseFilePath(directory)); + return { + available: stat.isFile(), + file_name: RELEASE_FILE_NAME, + size: stat.size, + updated_at: stat.mtime.toISOString(), + }; + } catch (error) { + if (error.code === 'ENOENT') { + return { available: false, file_name: RELEASE_FILE_NAME, size: 0, updated_at: null }; + } + throw error; + } +} + +module.exports = { + RELEASE_FILE_NAME, + isDmg, + releaseFilePath, + releaseInfo, + saveRelease, +}; diff --git a/server/src/lib/rollAccess.js b/server/src/lib/rollAccess.js new file mode 100644 index 0000000..328f7bc --- /dev/null +++ b/server/src/lib/rollAccess.js @@ -0,0 +1,43 @@ +const crypto = require('crypto'); + +const ACCESS_KEY_LIFETIME_MS = 24 * 60 * 60 * 1000; + +function generateAccessKey(randomBytes = crypto.randomBytes) { + return `CG-${randomBytes(18).toString('base64url')}`; +} + +function hashAccessKey(value) { + return crypto.createHash('sha256').update(String(value || '').trim()).digest('hex'); +} + +function accessKeyExpiry(now = Date.now()) { + return new Date(now + ACCESS_KEY_LIFETIME_MS); +} + +function normalizeAccessKeyId(value) { + if (typeof value === 'number') return Number.isSafeInteger(value) && value > 0 ? value : null; + if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) return null; + const id = Number(value); + return Number.isSafeInteger(id) ? id : null; +} + +function normalizeAccessKeyIds(value) { + if (!Array.isArray(value) || value.length < 1 || value.length > 100) return null; + const ids = value.map(normalizeAccessKeyId); + return ids.includes(null) ? null : [...new Set(ids)]; +} + +function rollAccessIdFromPayload(payload) { + const id = Number(payload.id); + return payload.purpose === 'roll-access' && Number.isSafeInteger(id) && id > 0 ? id : null; +} + +module.exports = { + ACCESS_KEY_LIFETIME_MS, + accessKeyExpiry, + generateAccessKey, + hashAccessKey, + normalizeAccessKeyId, + normalizeAccessKeyIds, + rollAccessIdFromPayload, +}; diff --git a/server/src/lib/secrets.js b/server/src/lib/secrets.js new file mode 100644 index 0000000..e279759 --- /dev/null +++ b/server/src/lib/secrets.js @@ -0,0 +1,44 @@ +const crypto = require('crypto'); +const config = require('../config'); + +const VERSION = 'v1'; + +function encryptionKey(secret = config.jwtSecret) { + if (!secret || secret.length < 32) { + throw new Error('JWT_SECRET must contain at least 32 characters before encrypted settings can be used.'); + } + return crypto.createHash('sha256').update(`cuegrove:settings:${VERSION}:${secret}`).digest(); +} + +function encryptSecret(value, secret = config.jwtSecret) { + if (!value) return ''; + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey(secret), iv); + const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return [VERSION, iv.toString('base64url'), tag.toString('base64url'), encrypted.toString('base64url')].join('.'); +} + +function decryptSecret(value, secret = config.jwtSecret) { + if (!value) return ''; + const [version, ivValue, tagValue, encryptedValue] = String(value).split('.'); + if (version !== VERSION || !ivValue || !tagValue || !encryptedValue) { + throw new Error('Encrypted setting has an unsupported format.'); + } + try { + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + encryptionKey(secret), + Buffer.from(ivValue, 'base64url'), + ); + decipher.setAuthTag(Buffer.from(tagValue, 'base64url')); + return Buffer.concat([ + decipher.update(Buffer.from(encryptedValue, 'base64url')), + decipher.final(), + ]).toString('utf8'); + } catch { + throw new Error('Encrypted setting could not be decrypted. Re-enter the secret in administrator settings.'); + } +} + +module.exports = { decryptSecret, encryptSecret }; diff --git a/server/src/lib/settings.js b/server/src/lib/settings.js new file mode 100644 index 0000000..26aad75 --- /dev/null +++ b/server/src/lib/settings.js @@ -0,0 +1,60 @@ +const db = require('../db'); + +const defaults = { + early_access: { + applications_open: true, + cohort: 'promptdock-early-access-1', + download_url: '', + feedback_url: '', + notify_email: '', + notify_on_new_application: false, + }, + mail_transport: { + configured: false, + enabled: false, + host: '', + port: 587, + secure: false, + auth_required: true, + timeout_ms: 20000, + helo_name: '', + user: '', + password_encrypted: '', + from: '', + reply_to: '', + }, +}; + +function parseSettingValue(value) { + let parsed = value; + if (Buffer.isBuffer(parsed)) parsed = parsed.toString('utf8'); + if (typeof parsed === 'string') parsed = JSON.parse(parsed); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Stored setting must be a JSON object.'); + } + return parsed; +} + +async function getSetting(key) { + const fallback = defaults[key]; + if (!fallback) throw new Error(`Unknown setting: ${key}`); + const [rows] = await db.query('SELECT setting_value FROM site_settings WHERE setting_key=? LIMIT 1', [key]); + if (!rows[0]) return { ...fallback }; + try { + return { ...fallback, ...parseSettingValue(rows[0].setting_value) }; + } catch { + return { ...fallback }; + } +} + +async function saveSetting(key, value) { + await db.query( + `INSERT INTO site_settings (setting_key, setting_value) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value), updated_at=CURRENT_TIMESTAMP`, + [key, JSON.stringify(value)], + ); + return value; +} + +module.exports = { defaults, getSetting, parseSettingValue, saveSetting }; diff --git a/server/src/lib/turnstile.js b/server/src/lib/turnstile.js new file mode 100644 index 0000000..006bd20 --- /dev/null +++ b/server/src/lib/turnstile.js @@ -0,0 +1,46 @@ +const config = require('../config'); + +const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; +const VERIFY_TIMEOUT_MS = 10000; + +async function verifyTurnstile({ + token, + remoteIp, + secret = config.turnstileSecret, + fetchImpl = globalThis.fetch, +}) { + if (!secret) return { success: false, reason: 'missing-secret' }; + if (!token) return { success: false, reason: 'missing-response' }; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS); + + try { + const response = await fetchImpl(SITEVERIFY_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + secret, + response: token, + remoteip: remoteIp, + }), + signal: controller.signal, + }); + const result = await response.json(); + const success = response.ok && result.success === true; + return { + success, + reason: success ? 'verified' : (response.ok ? 'rejected' : 'unavailable'), + errorCodes: Array.isArray(result['error-codes']) ? result['error-codes'] : [], + }; + } catch { + return { success: false, reason: 'unavailable' }; + } finally { + clearTimeout(timer); + } +} + +module.exports = { + SITEVERIFY_URL, + verifyTurnstile, +}; diff --git a/server/src/lib/validation.js b/server/src/lib/validation.js new file mode 100644 index 0000000..d48d274 --- /dev/null +++ b/server/src/lib/validation.js @@ -0,0 +1,65 @@ +const ALLOWED_ROLES = new Set(['student', 'creator', 'developer', 'researcher', 'product', 'other']); + +function string(value, max = 1000) { + return String(value == null ? '' : value).trim().slice(0, max); +} + +function normalizeEmail(value) { + return string(value, 160).toLowerCase(); +} + +function isEmail(value) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); +} + +function isHttpUrl(value, allowEmpty = true) { + if (!value) return allowEmpty; + try { + const parsed = new URL(value); + return parsed.protocol === 'https:' || parsed.protocol === 'http:'; + } catch { + return false; + } +} + +function passwordError(password) { + if (typeof password !== 'string' || password.length < 8 || password.length > 200) { + return 'Password must be between 8 and 200 characters.'; + } + if (!/[A-Za-z]/.test(password) || !/\d/.test(password)) { + return 'Password must include both letters and numbers.'; + } + return ''; +} + +function validateApplication(body = {}) { + const value = { + fullName: string(body.full_name, 80), + email: normalizeEmail(body.email), + role: string(body.role, 40), + useCase: string(body.use_case, 1500), + motivation: string(body.motivation, 1500), + locale: body.locale === 'en' ? 'en' : 'zh', + consent: body.consent === true, + honeypot: string(body.company_website, 300), + }; + + if (value.honeypot) return { ok: false, silent: true, value }; + if (!value.fullName || value.fullName.length < 2) return { ok: false, message: 'Please provide your name.', value }; + if (!isEmail(value.email)) return { ok: false, message: 'Please provide a valid email address.', value }; + if (!ALLOWED_ROLES.has(value.role)) return { ok: false, message: 'Please select a role.', value }; + if (value.useCase.length < 10) return { ok: false, message: 'Please tell us a little more about your use case.', value }; + if (value.motivation.length < 10) return { ok: false, message: 'Please tell us a little more about your motivation.', value }; + if (!value.consent) return { ok: false, message: 'Please accept the privacy notice.', value }; + return { ok: true, value }; +} + +module.exports = { + ALLOWED_ROLES, + isEmail, + isHttpUrl, + normalizeEmail, + passwordError, + string, + validateApplication, +}; diff --git a/server/src/middleware/auth.js b/server/src/middleware/auth.js new file mode 100644 index 0000000..1d29e0f --- /dev/null +++ b/server/src/middleware/auth.js @@ -0,0 +1,134 @@ +const jwt = require('jsonwebtoken'); +const db = require('../db'); +const config = require('../config'); + +const COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; + +function databaseFlag(value) { + return value === true || value === 1 || value === '1'; +} + +function ensureJwtConfigured() { + if (!config.jwtSecret || config.jwtSecret.length < 32) { + const error = new Error('JWT_SECRET must contain at least 32 characters.'); + error.code = 'AUTH_NOT_CONFIGURED'; + throw error; + } +} + +function cookieOptions() { + const options = { + httpOnly: true, + secure: config.cookie.secure, + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }; + if (config.cookie.domain) options.domain = config.cookie.domain; + return options; +} + +function publicAdmin(user) { + return { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + can_issue_roll_keys: databaseFlag(user.can_issue_roll_keys), + mfa_enabled: Boolean(user.mfa_enabled_at), + }; +} + +function issueSession(res, user) { + ensureJwtConfigured(); + const token = jwt.sign({ id: user.id, purpose: 'admin' }, config.jwtSecret, { expiresIn: '7d' }); + res.cookie(config.cookie.name, token, cookieOptions()); +} + +function adminIdFromPayload(payload) { + const id = Number(payload.id); + return payload.purpose === 'admin' && Number.isSafeInteger(id) && id > 0 ? id : null; +} + +function clearSession(res) { + const options = cookieOptions(); + delete options.maxAge; + res.clearCookie(config.cookie.name, options); +} + +async function requireAdmin(req, res, next) { + try { + ensureJwtConfigured(); + const token = req.cookies?.[config.cookie.name]; + if (!token) return res.status(401).json({ message: 'Administrator sign-in is required.' }); + const payload = jwt.verify(token, config.jwtSecret); + const adminId = adminIdFromPayload(payload); + if (!adminId) return res.status(401).json({ message: 'Administrator session is invalid.' }); + const [rows] = await db.query( + 'SELECT id, name, email, role, status, can_issue_roll_keys, mfa_enabled_at FROM admin_users WHERE id=? LIMIT 1', + [adminId], + ); + const admin = rows[0]; + if (!admin) return res.status(401).json({ message: 'Administrator session is no longer valid.' }); + if (admin.status !== 'active') return res.status(403).json({ message: 'This administrator account is disabled.' }); + if (!['owner', 'admin'].includes(admin.role)) return res.status(403).json({ message: 'Administrator permission is required.' }); + req.admin = admin; + next(); + } catch (error) { + if (error.code === 'AUTH_NOT_CONFIGURED') return res.status(503).json({ message: error.message }); + return res.status(401).json({ message: 'Administrator session expired. Please sign in again.' }); + } +} + +async function optionalAdminSession(req) { + ensureJwtConfigured(); + const token = req.cookies?.[config.cookie.name]; + if (!token) return null; + let payload; + try { + payload = jwt.verify(token, config.jwtSecret); + } catch { + return null; + } + const adminId = adminIdFromPayload(payload); + if (!adminId) return null; + const [rows] = await db.query( + 'SELECT id, name, email, role, status, can_issue_roll_keys, mfa_enabled_at FROM admin_users WHERE id=? LIMIT 1', + [adminId], + ); + const admin = rows[0]; + return admin?.status === 'active' && ['owner', 'admin'].includes(admin.role) ? admin : null; +} + +function requireOwner(req, res, next) { + if (req.admin?.role !== 'owner') { + return res.status(403).json({ message: 'Owner permission is required for this action.' }); + } + next(); +} + +function requireRollKeyIssuer(req, res, next) { + if (req.admin?.role !== 'owner' && !databaseFlag(req.admin?.can_issue_roll_keys)) { + return res.status(403).json({ message: 'Tool access-key permission is required for this action.' }); + } + next(); +} + +function requireManagementAdmin(req, res, next) { + if (req.admin?.role === 'admin' && databaseFlag(req.admin?.can_issue_roll_keys)) { + return res.status(403).json({ message: 'This account is limited to issuing tool access keys.' }); + } + next(); +} + +module.exports = { + clearSession, + databaseFlag, + issueSession, + publicAdmin, + optionalAdminSession, + requireAdmin, + requireManagementAdmin, + requireOwner, + requireRollKeyIssuer, +}; diff --git a/server/src/middleware/security.js b/server/src/middleware/security.js new file mode 100644 index 0000000..16e364e --- /dev/null +++ b/server/src/middleware/security.js @@ -0,0 +1,12 @@ +const config = require('../config'); + +function requireSameOrigin(req, res, next) { + if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); + const origin = req.get('origin'); + if (!origin || !config.allowedOrigins.has(origin)) { + return res.status(403).json({ message: 'This request did not come from an allowed site origin.' }); + } + next(); +} + +module.exports = { requireSameOrigin }; diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js new file mode 100644 index 0000000..d89d476 --- /dev/null +++ b/server/src/routes/admin.js @@ -0,0 +1,592 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const db = require('../db'); +const config = require('../config'); +const { databaseFlag, requireAdmin, requireManagementAdmin, requireOwner } = require('../middleware/auth'); +const { getSetting, saveSetting } = require('../lib/settings'); +const { mailReady, resolveMailConfig, sendDecisionEmail, sendTestEmail } = require('../lib/mailer'); +const { encryptSecret } = require('../lib/secrets'); +const { issueFeedbackToken } = require('./feedback'); +const { isDmg, releaseInfo, saveRelease } = require('../lib/releaseStorage'); +const { isEmail, isHttpUrl, normalizeEmail, passwordError, string } = require('../lib/validation'); + +const router = express.Router(); +router.use(requireAdmin, requireManagementAdmin); + +function publicManagedAdmin(user) { + return { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + status: user.status, + can_issue_roll_keys: databaseFlag(user.can_issue_roll_keys), + failed_login_count: user.failed_login_count, + locked_until: user.locked_until, + last_login_at: user.last_login_at, + created_at: user.created_at, + mfa_enabled: Boolean(user.mfa_enabled_at), + }; +} + +function mailboxEmail(value) { + const source = string(value, 255); + const bracketed = source.match(/<([^<>]+)>$/); + return bracketed ? normalizeEmail(bracketed[1]) : normalizeEmail(source); +} + +function validMailHost(value) { + return !value || /^[a-zA-Z0-9][a-zA-Z0-9.-]{0,253}$/.test(value); +} + +function publicMailSettings(stored, resolved) { + return { + configured: stored.configured === true, + source: resolved.source, + enabled: resolved.enabled === true, + ready: mailReady(resolved), + host: resolved.host, + port: resolved.port, + secure: resolved.secure === true, + auth_required: resolved.authRequired === true, + timeout_ms: resolved.timeoutMs, + helo_name: resolved.heloName, + user: resolved.user, + password_configured: Boolean(resolved.pass), + from: resolved.from, + reply_to: resolved.replyTo, + configuration_error: resolved.configurationError || '', + }; +} + +router.get('/users', requireOwner, async (_req, res, next) => { + try { + const [rows] = await db.query( + `SELECT id, name, email, role, status, can_issue_roll_keys, failed_login_count, locked_until, last_login_at, created_at, mfa_enabled_at + FROM admin_users ORDER BY CASE WHEN role='owner' THEN 0 ELSE 1 END, created_at ASC`, + ); + res.json({ users: rows.map(publicManagedAdmin) }); + } catch (error) { + next(error); + } +}); + +router.post('/users', requireOwner, async (req, res, next) => { + try { + const name = string(req.body.name, 80); + const email = normalizeEmail(req.body.email); + const role = req.body.role === 'owner' ? 'owner' : 'admin'; + const password = req.body.password; + if (name.length < 2) return res.status(400).json({ message: 'Administrator name must contain at least 2 characters.' }); + if (!isEmail(email)) return res.status(400).json({ message: 'Administrator email is invalid.' }); + const invalidPassword = passwordError(password); + if (invalidPassword) return res.status(400).json({ message: invalidPassword }); + const passwordHash = await bcrypt.hash(password, 12); + const [result] = await db.query( + `INSERT INTO admin_users (name, email, password_hash, role, status) + VALUES (?, ?, ?, ?, 'active')`, + [name, email, passwordHash, role], + ); + const [rows] = await db.query( + `SELECT id, name, email, role, status, can_issue_roll_keys, failed_login_count, locked_until, last_login_at, created_at, mfa_enabled_at + FROM admin_users WHERE id=? LIMIT 1`, + [result.insertId], + ); + res.status(201).json({ user: publicManagedAdmin(rows[0]) }); + } catch (error) { + if (error.code === 'ER_DUP_ENTRY') return res.status(409).json({ message: 'An administrator with this email already exists.' }); + next(error); + } +}); + +router.patch('/users/:id', requireOwner, async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id < 1) return res.status(400).json({ message: 'Administrator id is invalid.' }); + const name = string(req.body.name, 80); + const email = normalizeEmail(req.body.email); + const role = req.body.role; + const status = req.body.status; + const canIssueRollKeys = req.body.can_issue_roll_keys === true && role === 'admin' && status === 'active'; + if (name.length < 2) return res.status(400).json({ message: 'Administrator name must contain at least 2 characters.' }); + if (!isEmail(email)) return res.status(400).json({ message: 'Administrator email is invalid.' }); + if (!['owner', 'admin'].includes(role)) return res.status(400).json({ message: 'Administrator role is invalid.' }); + if (!['active', 'disabled'].includes(status)) return res.status(400).json({ message: 'Administrator status is invalid.' }); + if (id === req.admin.id && status === 'disabled') { + return res.status(409).json({ message: 'You cannot disable your own administrator account.' }); + } + + let connection; + try { + connection = await db.getConnection(); + await connection.beginTransaction(); + const [rows] = await connection.query('SELECT * FROM admin_users WHERE id=? FOR UPDATE', [id]); + const current = rows[0]; + if (!current) { + await connection.rollback(); + return res.status(404).json({ message: 'Administrator not found.' }); + } + const removesActiveOwner = current.role === 'owner' && current.status === 'active' && (role !== 'owner' || status !== 'active'); + if (removesActiveOwner) { + const [ownerRows] = await connection.query( + "SELECT id FROM admin_users WHERE role='owner' AND status='active' FOR UPDATE", + ); + if (ownerRows.length <= 1) { + await connection.rollback(); + return res.status(409).json({ message: 'At least one active owner account is required.' }); + } + } + if (canIssueRollKeys) { + await connection.query('UPDATE admin_users SET can_issue_roll_keys=0 WHERE id<>?', [id]); + } + await connection.query( + `UPDATE admin_users + SET name=?, email=?, role=?, status=?, can_issue_roll_keys=?, + failed_login_count=IF(?='active', 0, failed_login_count), + locked_until=IF(?='active', NULL, locked_until) + WHERE id=?`, + [name, email, role, status, canIssueRollKeys, status, status, id], + ); + await connection.commit(); + const [updatedRows] = await db.query( + `SELECT id, name, email, role, status, can_issue_roll_keys, failed_login_count, locked_until, last_login_at, created_at, mfa_enabled_at + FROM admin_users WHERE id=? LIMIT 1`, + [id], + ); + res.json({ user: publicManagedAdmin(updatedRows[0]) }); + } catch (error) { + if (connection) await connection.rollback().catch(() => undefined); + if (error.code === 'ER_DUP_ENTRY') return res.status(409).json({ message: 'An administrator with this email already exists.' }); + next(error); + } finally { + if (connection) connection.release(); + } +}); + +router.post('/users/:id/reset-password', requireOwner, async (req, res, next) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id < 1) return res.status(400).json({ message: 'Administrator id is invalid.' }); + const invalidPassword = passwordError(req.body.password); + if (invalidPassword) return res.status(400).json({ message: invalidPassword }); + const passwordHash = await bcrypt.hash(req.body.password, 12); + const [result] = await db.query( + `UPDATE admin_users + SET password_hash=?, failed_login_count=0, locked_until=NULL + WHERE id=?`, + [passwordHash, id], + ); + if (!result.affectedRows) return res.status(404).json({ message: 'Administrator not found.' }); + await db.query('UPDATE admin_password_resets SET used_at=NOW() WHERE admin_id=? AND used_at IS NULL', [id]); + res.json({ message: 'Administrator password updated and account lock cleared.' }); + } catch (error) { + next(error); + } +}); + +const applicationSelect = ` + SELECT + ea.*, + reviewer.name AS reviewer_name, + ( + SELECT mdl.status + FROM mail_delivery_logs mdl + WHERE mdl.application_id=ea.id AND mdl.mail_kind IN ('approved','rejected') + ORDER BY mdl.id DESC + LIMIT 1 + ) AS latest_email_status, + ( + SELECT mdl.error_message + FROM mail_delivery_logs mdl + WHERE mdl.application_id=ea.id AND mdl.mail_kind IN ('approved','rejected') + ORDER BY mdl.id DESC + LIMIT 1 + ) AS latest_email_error + FROM early_access_applications ea + LEFT JOIN admin_users reviewer ON reviewer.id=ea.reviewer_id +`; + +router.get('/early-access', async (req, res, next) => { + try { + const status = string(req.query.status, 20) || 'pending'; + const keyword = string(req.query.keyword, 100); + if (!['all', 'pending', 'approved', 'rejected'].includes(status)) { + return res.status(400).json({ message: 'Application status filter is invalid.' }); + } + + const where = []; + const params = []; + if (status !== 'all') { + where.push('ea.status=?'); + params.push(status); + } + if (keyword) { + const match = `%${keyword}%`; + where.push('(ea.full_name LIKE ? OR ea.email LIKE ? OR ea.role LIKE ? OR ea.use_case LIKE ? OR ea.motivation LIKE ?)'); + params.push(match, match, match, match, match); + } + + const [rows] = await db.query( + `${applicationSelect} + ${where.length ? `WHERE ${where.join(' AND ')}` : ''} + ORDER BY CASE WHEN ea.status='pending' THEN 0 ELSE 1 END, ea.created_at DESC + LIMIT 300`, + params, + ); + res.json({ applications: rows }); + } catch (error) { + next(error); + } +}); + +router.patch('/early-access/:id/review', async (req, res, next) => { + const id = Number(req.params.id); + const decision = req.body.decision; + const internalNote = string(req.body.internal_note, 2000); + const applicantMessage = string(req.body.applicant_message, 1500); + if (!Number.isInteger(id) || id < 1) return res.status(400).json({ message: 'Application id is invalid.' }); + if (!['approved', 'rejected'].includes(decision)) return res.status(400).json({ message: 'Decision must be approved or rejected.' }); + + let application; + let settings; + let feedbackToken; + let connection; + try { + settings = await getSetting('early_access'); + if (decision === 'approved' && !settings.download_url) { + return res.status(409).json({ message: 'Configure the PromptDock download URL before approving applications.' }); + } + + connection = await db.getConnection(); + await connection.beginTransaction(); + const [rows] = await connection.query( + 'SELECT * FROM early_access_applications WHERE id=? FOR UPDATE', + [id], + ); + application = rows[0]; + if (!application) { + await connection.rollback(); + return res.status(404).json({ message: 'Application not found.' }); + } + if (application.status !== 'pending') { + await connection.rollback(); + return res.status(409).json({ message: 'This application has already been reviewed.' }); + } + await connection.query( + `UPDATE early_access_applications + SET status=?, reviewer_id=?, internal_note=?, applicant_message=?, reviewed_at=NOW() + WHERE id=?`, + [decision, req.admin.id, internalNote || null, applicantMessage || null, id], + ); + await connection.commit(); + application = { + ...application, + status: decision, + reviewer_id: req.admin.id, + internal_note: internalNote, + applicant_message: applicantMessage, + }; + if (decision === 'approved') { + feedbackToken = await issueFeedbackToken(id); + } + } catch (error) { + if (connection) await connection.rollback().catch(() => undefined); + return next(error); + } finally { + if (connection) connection.release(); + } + + try { + const deliverySettings = feedbackToken + ? { ...settings, feedback_url: `${config.siteUrl}/feedback/${feedbackToken}` } + : settings; + const delivery = await sendDecisionEmail(application, deliverySettings); + return res.json({ + message: decision === 'approved' ? 'Application approved.' : 'Application rejected.', + email_status: delivery.status, + email_error: delivery.error || null, + }); + } catch (error) { + return next(error); + } +}); + +router.post('/early-access/:id/retry-email', async (req, res, next) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id < 1) return res.status(400).json({ message: 'Application id is invalid.' }); + const [rows] = await db.query('SELECT * FROM early_access_applications WHERE id=? LIMIT 1', [id]); + const application = rows[0]; + if (!application) return res.status(404).json({ message: 'Application not found.' }); + if (!['approved', 'rejected'].includes(application.status)) { + return res.status(409).json({ message: 'Only reviewed applications have a decision email.' }); + } + const [logRows] = await db.query( + `SELECT status FROM mail_delivery_logs + WHERE application_id=? AND mail_kind IN ('approved','rejected') + ORDER BY id DESC LIMIT 1`, + [id], + ); + if (logRows[0]?.status === 'sent') { + return res.status(409).json({ message: 'The latest decision email was already sent.' }); + } + const settings = await getSetting('early_access'); + if (application.status === 'approved' && !settings.download_url) { + return res.status(409).json({ message: 'Configure the PromptDock download URL before retrying this email.' }); + } + const feedbackToken = application.status === 'approved' ? await issueFeedbackToken(id) : null; + const deliverySettings = feedbackToken + ? { ...settings, feedback_url: `${config.siteUrl}/feedback/${feedbackToken}` } + : settings; + const delivery = await sendDecisionEmail(application, deliverySettings); + if (delivery.status === 'failed') { + return res.status(502).json({ message: `Email delivery failed: ${delivery.error}` }); + } + return res.json({ message: 'Decision email sent.' }); + } catch (error) { + next(error); + } +}); + +router.post('/early-access/resend-approved', requireOwner, async (req, res, next) => { + if (req.body?.confirm !== true) { + return res.status(400).json({ message: 'Explicit confirmation is required before resending approval emails.' }); + } + + const hasRequestedIds = Array.isArray(req.body.ids); + const requestedIds = hasRequestedIds + ? [...new Set(req.body.ids.map(Number).filter((id) => Number.isInteger(id) && id > 0))] + : null; + if (hasRequestedIds && requestedIds.length === 0) { + return res.status(400).json({ message: 'Select at least one approved application.' }); + } + if (requestedIds && requestedIds.length > 1000) { + return res.status(400).json({ message: 'You can resend at most 1000 approval emails at once.' }); + } + + try { + const settings = await getSetting('early_access'); + if (!settings.download_url) { + return res.status(409).json({ message: 'Configure the PromptDock download URL before resending approval emails.' }); + } + + const params = requestedIds || []; + const [applications] = await db.query( + `SELECT * FROM early_access_applications + WHERE status='approved' ${requestedIds ? `AND id IN (${requestedIds.map(() => '?').join(',')})` : ''} + ORDER BY id ASC + LIMIT 1000`, + params, + ); + + const results = []; + for (const application of applications) { + try { + const feedbackToken = await issueFeedbackToken(application.id); + const delivery = await sendDecisionEmail(application, { + ...settings, + feedback_url: `${config.siteUrl}/feedback/${feedbackToken}`, + }); + results.push({ id: application.id, status: delivery.status, error: delivery.error || null }); + } catch (error) { + results.push({ id: application.id, status: 'failed', error: String(error.message || 'Email delivery failed.') }); + } + } + + const sent = results.filter((item) => item.status === 'sent').length; + const failed = results.length - sent; + return res.json({ + message: `Resent approval emails: ${sent} sent, ${failed} failed.`, + total: results.length, + sent, + failed, + results, + }); + } catch (error) { + return next(error); + } +}); + +router.get('/settings/early-access', async (_req, res, next) => { + try { + res.json({ settings: await getSetting('early_access') }); + } catch (error) { + next(error); + } +}); + +router.put('/settings/early-access', async (req, res, next) => { + try { + const cohort = string(req.body.cohort, 80); + const downloadUrl = string(req.body.download_url, 500); + const feedbackUrl = string(req.body.feedback_url, 500); + const notifyEmail = normalizeEmail(req.body.notify_email); + if (!cohort || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{2,79}$/.test(cohort)) { + return res.status(400).json({ message: 'Cohort must use 3–80 letters, numbers, dots, underscores, or hyphens.' }); + } + if (!isHttpUrl(downloadUrl)) return res.status(400).json({ message: 'Download URL must be a valid HTTP or HTTPS URL.' }); + if (!isHttpUrl(feedbackUrl)) return res.status(400).json({ message: 'Feedback URL must be a valid HTTP or HTTPS URL.' }); + if (notifyEmail && !isEmail(notifyEmail)) return res.status(400).json({ message: 'Notification email is invalid.' }); + + const settings = { + applications_open: req.body.applications_open === true, + cohort, + download_url: downloadUrl, + feedback_url: feedbackUrl, + notify_email: notifyEmail, + notify_on_new_application: req.body.notify_on_new_application === true, + }; + await saveSetting('early_access', settings); + res.json({ settings }); + } catch (error) { + next(error); + } +}); + +router.get('/releases/promptdock', requireOwner, async (_req, res, next) => { + try { + res.json({ release: await releaseInfo() }); + } catch (error) { + next(error); + } +}); + +router.post( + '/releases/promptdock', + requireOwner, + express.raw({ type: ['application/x-apple-diskimage', 'application/octet-stream'], limit: '128mb' }), + async (req, res, next) => { + try { + let fileName = ''; + try { + fileName = decodeURIComponent(req.get('x-file-name') || ''); + } catch { + return res.status(400).json({ message: 'The upload file name is invalid.' }); + } + if (!fileName.toLowerCase().endsWith('.dmg')) { + return res.status(400).json({ message: 'Only DMG installers can be uploaded.' }); + } + if (!isDmg(req.body)) { + return res.status(400).json({ message: 'The uploaded file is not a valid DMG image.' }); + } + + const release = await saveRelease(req.body); + const current = await getSetting('early_access'); + const settings = { + ...current, + download_url: `${config.siteUrl}/api/releases/promptdock`, + }; + await saveSetting('early_access', settings); + return res.json({ release, settings }); + } catch (error) { + return next(error); + } + }, +); + +router.get('/settings/mail', requireOwner, async (_req, res, next) => { + try { + const [stored, resolved] = await Promise.all([ + getSetting('mail_transport'), + resolveMailConfig(), + ]); + res.json({ settings: publicMailSettings(stored, resolved) }); + } catch (error) { + next(error); + } +}); + +router.put('/settings/mail', requireOwner, async (req, res, next) => { + try { + const current = await getSetting('mail_transport'); + const enabled = req.body.enabled === true; + const host = string(req.body.host, 255).toLowerCase(); + const port = Number(req.body.port); + const timeoutMs = Number(req.body.timeout_ms); + const secure = req.body.secure === true; + const authRequired = req.body.auth_required === true; + const heloName = string(req.body.helo_name, 255).toLowerCase(); + const user = string(req.body.user, 255); + const from = string(req.body.from, 255); + const replyTo = string(req.body.reply_to, 255); + const password = typeof req.body.password === 'string' ? req.body.password.slice(0, 500) : ''; + if (!validMailHost(host)) return res.status(400).json({ message: 'SMTP host must be a hostname or IP address without a protocol.' }); + if (!validMailHost(heloName)) return res.status(400).json({ message: 'HELO name must be a hostname without a protocol.' }); + if (!Number.isInteger(port) || port < 1 || port > 65535) return res.status(400).json({ message: 'SMTP port must be between 1 and 65535.' }); + if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 120000) { + return res.status(400).json({ message: 'SMTP timeout must be between 1000 and 120000 milliseconds.' }); + } + if (from && !isEmail(mailboxEmail(from))) return res.status(400).json({ message: 'From address is invalid.' }); + if (replyTo && !isEmail(mailboxEmail(replyTo))) return res.status(400).json({ message: 'Reply-to address is invalid.' }); + + let passwordEncrypted = current.password_encrypted || ''; + if (req.body.clear_password === true) passwordEncrypted = ''; + else if (password) passwordEncrypted = encryptSecret(password); + else if (!passwordEncrypted && config.mail.pass) passwordEncrypted = encryptSecret(config.mail.pass); + + if (enabled && (!host || !from)) { + return res.status(400).json({ message: 'SMTP host and From address are required when mail is enabled.' }); + } + if (enabled && authRequired && (!user || !passwordEncrypted)) { + return res.status(400).json({ message: 'SMTP username and password are required when authentication is enabled.' }); + } + + const stored = { + configured: true, + enabled, + host, + port, + secure, + auth_required: authRequired, + timeout_ms: timeoutMs, + helo_name: heloName, + user, + password_encrypted: passwordEncrypted, + from, + reply_to: replyTo, + }; + await saveSetting('mail_transport', stored); + const resolved = await resolveMailConfig(); + res.json({ settings: publicMailSettings(stored, resolved) }); + } catch (error) { + next(error); + } +}); + +router.get('/settings/mail/status', async (_req, res, next) => { + try { + const [stored, resolved] = await Promise.all([ + getSetting('mail_transport'), + resolveMailConfig(), + ]); + const settings = publicMailSettings(stored, resolved); + res.json({ + mail: { + enabled: settings.ready, + host_configured: Boolean(settings.host), + auth_required: settings.auth_required, + user_configured: Boolean(settings.user), + password_configured: settings.password_configured, + from: settings.from, + source: settings.source, + }, + }); + } catch (error) { + next(error); + } +}); + +router.post('/settings/mail/test', requireOwner, async (req, res, next) => { + try { + const settings = await getSetting('early_access'); + const recipient = settings.notify_email || req.admin.email; + const delivery = await sendTestEmail(recipient); + if (delivery.status === 'failed') { + return res.status(502).json({ message: `Test email failed: ${delivery.error}` }); + } + res.json({ message: `Test email sent to ${recipient}.` }); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js new file mode 100644 index 0000000..5973071 --- /dev/null +++ b/server/src/routes/auth.js @@ -0,0 +1,446 @@ +const crypto = require('crypto'); +const express = require('express'); +const rateLimit = require('express-rate-limit'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const QRCode = require('qrcode'); +const db = require('../db'); +const config = require('../config'); +const { clearSession, issueSession, publicAdmin, requireAdmin } = require('../middleware/auth'); +const { + buildOtpAuthUri, + findRecoveryCodeIndex, + generateRecoveryCodes, + generateTotpSecret, + hashRecoveryCode, + matchingTotpStep, + parseRecoveryHashes, +} = require('../lib/mfa'); +const { decryptSecret, encryptSecret } = require('../lib/secrets'); +const { normalizeEmail, passwordError, string } = require('../lib/validation'); +const { sendPasswordReset } = require('../lib/mailer'); + +const router = express.Router(); +const MAX_FAILURES = 5; +const LOCK_MINUTES = 15; +const GENERIC_RESET_MESSAGE = 'If that administrator email exists, a reset link will be sent.'; + +const loginLimit = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 12, + standardHeaders: true, + legacyHeaders: false, + message: { message: 'Too many sign-in attempts. Please try again later.' }, +}); + +const resetLimit = rateLimit({ + windowMs: 60 * 60 * 1000, + limit: 5, + standardHeaders: true, + legacyHeaders: false, + message: { message: 'Too many password reset requests. Please try again later.' }, +}); + +const mfaLimit = rateLimit({ + windowMs: 10 * 60 * 1000, + limit: 12, + standardHeaders: true, + legacyHeaders: false, + message: { message: 'Too many verification attempts. Please try again later.' }, +}); + +function createMfaChallenge(admin) { + if (!config.jwtSecret || config.jwtSecret.length < 32) { + const error = new Error('JWT_SECRET must contain at least 32 characters.'); + error.code = 'AUTH_NOT_CONFIGURED'; + throw error; + } + return jwt.sign( + { id: admin.id, purpose: 'mfa-login' }, + config.jwtSecret, + { expiresIn: '5m' }, + ); +} + +function verifyMfaChallenge(token) { + const payload = jwt.verify(String(token || ''), config.jwtSecret); + if (payload.purpose !== 'mfa-login' || !Number.isInteger(Number(payload.id))) { + throw new Error('Invalid MFA challenge.'); + } + return Number(payload.id); +} + +function verifySecondFactor(admin, code, { allowRecovery = true, requireTotp = false } = {}) { + if (!admin.mfa_secret_encrypted || !admin.mfa_enabled_at) return { ok: false }; + const secret = decryptSecret(admin.mfa_secret_encrypted); + const step = matchingTotpStep(secret, code, { + lastUsedStep: admin.mfa_last_used_step === null ? null : Number(admin.mfa_last_used_step), + }); + if (step !== null) return { ok: true, type: 'totp', step }; + if (!allowRecovery || requireTotp) return { ok: false }; + + const hashes = parseRecoveryHashes(admin.mfa_recovery_hashes); + const recoveryIndex = findRecoveryCodeIndex(hashes, code); + if (recoveryIndex < 0) return { ok: false }; + hashes.splice(recoveryIndex, 1); + return { ok: true, type: 'recovery', recoveryHashes: hashes }; +} + +async function recordSecondFactorUse(connection, adminId, verification) { + if (verification.type === 'totp') { + await connection.query('UPDATE admin_users SET mfa_last_used_step=? WHERE id=?', [verification.step, adminId]); + } else { + await connection.query( + 'UPDATE admin_users SET mfa_recovery_hashes=? WHERE id=?', + [JSON.stringify(verification.recoveryHashes), adminId], + ); + } +} + +router.post('/login', loginLimit, async (req, res, next) => { + try { + const email = normalizeEmail(req.body.email); + const password = String(req.body.password || ''); + if (!email || !password) return res.status(400).json({ message: 'Email and password are required.' }); + + const [rows] = await db.query('SELECT * FROM admin_users WHERE email=? LIMIT 1', [email]); + const admin = rows[0]; + if (!admin) return res.status(401).json({ message: 'Email or password is incorrect.' }); + if (admin.status !== 'active') return res.status(403).json({ message: 'This administrator account is disabled.' }); + if (admin.locked_until && new Date(admin.locked_until).getTime() > Date.now()) { + return res.status(423).json({ message: 'This account is temporarily locked. Please try again later.' }); + } + + const matches = await bcrypt.compare(password, admin.password_hash); + if (!matches) { + const failures = Number(admin.failed_login_count || 0) + 1; + if (failures >= MAX_FAILURES) { + await db.query( + 'UPDATE admin_users SET failed_login_count=?, locked_until=DATE_ADD(NOW(), INTERVAL ? MINUTE) WHERE id=?', + [failures, LOCK_MINUTES, admin.id], + ); + return res.status(423).json({ message: 'Too many failed attempts. This account is locked for 15 minutes.' }); + } + await db.query('UPDATE admin_users SET failed_login_count=?, locked_until=NULL WHERE id=?', [failures, admin.id]); + return res.status(401).json({ message: 'Email or password is incorrect.' }); + } + + await db.query('UPDATE admin_users SET failed_login_count=0, locked_until=NULL WHERE id=?', [admin.id]); + if (admin.mfa_enabled_at) { + if (!admin.mfa_secret_encrypted) { + return res.status(503).json({ message: 'Multi-factor authentication is not configured correctly for this account.' }); + } + return res.json({ + mfa_required: true, + challenge_token: createMfaChallenge(admin), + }); + } + + await db.query('UPDATE admin_users SET last_login_at=NOW() WHERE id=?', [admin.id]); + issueSession(res, admin); + return res.json({ user: publicAdmin(admin) }); + } catch (error) { + next(error); + } +}); + +router.post('/mfa/verify-login', mfaLimit, async (req, res, next) => { + let connection; + try { + let adminId; + try { + adminId = verifyMfaChallenge(req.body.challenge_token); + } catch { + return res.status(401).json({ message: 'This verification request is invalid or expired. Sign in again.' }); + } + const code = string(req.body.code, 80); + if (!code) return res.status(400).json({ message: 'Authenticator or recovery code is required.' }); + + connection = await db.getConnection(); + await connection.beginTransaction(); + const [rows] = await connection.query('SELECT * FROM admin_users WHERE id=? FOR UPDATE', [adminId]); + const admin = rows[0]; + if (!admin || admin.status !== 'active') { + await connection.rollback(); + return res.status(401).json({ message: 'This verification request is no longer valid.' }); + } + if (admin.locked_until && new Date(admin.locked_until).getTime() > Date.now()) { + await connection.rollback(); + return res.status(423).json({ message: 'This account is temporarily locked. Please try again later.' }); + } + const verification = verifySecondFactor(admin, code); + if (!verification.ok) { + await connection.rollback(); + return res.status(401).json({ message: 'Authenticator or recovery code is incorrect.' }); + } + await recordSecondFactorUse(connection, admin.id, verification); + await connection.query( + 'UPDATE admin_users SET failed_login_count=0, locked_until=NULL, last_login_at=NOW() WHERE id=?', + [admin.id], + ); + await connection.commit(); + issueSession(res, admin); + return res.json({ + user: publicAdmin(admin), + recovery_code_used: verification.type === 'recovery', + recovery_codes_remaining: verification.type === 'recovery' ? verification.recoveryHashes.length : undefined, + }); + } catch (error) { + if (connection) await connection.rollback().catch(() => undefined); + next(error); + } finally { + if (connection) connection.release(); + } +}); + +router.get('/mfa/status', requireAdmin, async (req, res, next) => { + try { + const [rows] = await db.query( + 'SELECT mfa_enabled_at, mfa_recovery_hashes FROM admin_users WHERE id=? LIMIT 1', + [req.admin.id], + ); + const admin = rows[0]; + res.json({ + enabled: Boolean(admin?.mfa_enabled_at), + enabled_at: admin?.mfa_enabled_at || null, + recovery_codes_remaining: parseRecoveryHashes(admin?.mfa_recovery_hashes).length, + }); + } catch (error) { + next(error); + } +}); + +router.post('/mfa/setup', requireAdmin, mfaLimit, async (req, res, next) => { + try { + const [rows] = await db.query('SELECT mfa_enabled_at FROM admin_users WHERE id=? LIMIT 1', [req.admin.id]); + if (rows[0]?.mfa_enabled_at) { + return res.status(409).json({ message: 'Multi-factor authentication is already enabled.' }); + } + const secret = generateTotpSecret(); + const otpauthUri = buildOtpAuthUri({ secret, email: req.admin.email }); + const qrDataUrl = await QRCode.toDataURL(otpauthUri, { + errorCorrectionLevel: 'M', + margin: 2, + width: 280, + color: { dark: '#0b503f', light: '#fffdf7' }, + }); + await db.query( + `UPDATE admin_users + SET mfa_secret_encrypted=?, mfa_setup_expires_at=DATE_ADD(NOW(), INTERVAL 15 MINUTE), + mfa_recovery_hashes=NULL, mfa_last_used_step=NULL + WHERE id=?`, + [encryptSecret(secret), req.admin.id], + ); + return res.json({ + secret, + otpauth_uri: otpauthUri, + qr_data_url: qrDataUrl, + expires_in_minutes: 15, + }); + } catch (error) { + next(error); + } +}); + +router.post('/mfa/enable', requireAdmin, mfaLimit, async (req, res, next) => { + let connection; + try { + const code = string(req.body.code, 20); + if (!code) return res.status(400).json({ message: 'Authenticator code is required.' }); + connection = await db.getConnection(); + await connection.beginTransaction(); + const [rows] = await connection.query('SELECT * FROM admin_users WHERE id=? FOR UPDATE', [req.admin.id]); + const admin = rows[0]; + if (admin.mfa_enabled_at) { + await connection.rollback(); + return res.status(409).json({ message: 'Multi-factor authentication is already enabled.' }); + } + if ( + !admin.mfa_secret_encrypted + || !admin.mfa_setup_expires_at + || new Date(admin.mfa_setup_expires_at).getTime() <= Date.now() + ) { + await connection.rollback(); + return res.status(400).json({ message: 'This setup expired. Start multi-factor authentication setup again.' }); + } + const secret = decryptSecret(admin.mfa_secret_encrypted); + const step = matchingTotpStep(secret, code); + if (step === null) { + await connection.rollback(); + return res.status(401).json({ message: 'Authenticator code is incorrect.' }); + } + const recoveryCodes = generateRecoveryCodes(); + await connection.query( + `UPDATE admin_users + SET mfa_enabled_at=NOW(), mfa_setup_expires_at=NULL, mfa_last_used_step=?, + mfa_recovery_hashes=? + WHERE id=?`, + [step, JSON.stringify(recoveryCodes.map(hashRecoveryCode)), admin.id], + ); + await connection.commit(); + return res.json({ + user: publicAdmin({ ...admin, mfa_enabled_at: new Date() }), + recovery_codes: recoveryCodes, + }); + } catch (error) { + if (connection) await connection.rollback().catch(() => undefined); + next(error); + } finally { + if (connection) connection.release(); + } +}); + +router.post('/mfa/recovery-codes', requireAdmin, mfaLimit, async (req, res, next) => { + let connection; + try { + const code = string(req.body.code, 20); + if (!code) return res.status(400).json({ message: 'Current authenticator code is required.' }); + connection = await db.getConnection(); + await connection.beginTransaction(); + const [rows] = await connection.query('SELECT * FROM admin_users WHERE id=? FOR UPDATE', [req.admin.id]); + const admin = rows[0]; + const verification = verifySecondFactor(admin, code, { allowRecovery: false, requireTotp: true }); + if (!verification.ok) { + await connection.rollback(); + return res.status(401).json({ message: 'Authenticator code is incorrect.' }); + } + const recoveryCodes = generateRecoveryCodes(); + await connection.query( + 'UPDATE admin_users SET mfa_last_used_step=?, mfa_recovery_hashes=? WHERE id=?', + [verification.step, JSON.stringify(recoveryCodes.map(hashRecoveryCode)), admin.id], + ); + await connection.commit(); + return res.json({ recovery_codes: recoveryCodes }); + } catch (error) { + if (connection) await connection.rollback().catch(() => undefined); + next(error); + } finally { + if (connection) connection.release(); + } +}); + +router.delete('/mfa', requireAdmin, mfaLimit, async (req, res, next) => { + let connection; + try { + const password = String(req.body.password || ''); + const code = string(req.body.code, 80); + if (!password || !code) { + return res.status(400).json({ message: 'Current password and authenticator or recovery code are required.' }); + } + connection = await db.getConnection(); + await connection.beginTransaction(); + const [rows] = await connection.query('SELECT * FROM admin_users WHERE id=? FOR UPDATE', [req.admin.id]); + const admin = rows[0]; + if (!admin?.mfa_enabled_at) { + await connection.rollback(); + return res.status(409).json({ message: 'Multi-factor authentication is not enabled.' }); + } + const passwordMatches = await bcrypt.compare(password, admin.password_hash); + if (!passwordMatches) { + await connection.rollback(); + return res.status(401).json({ message: 'Current password is incorrect.' }); + } + const verification = verifySecondFactor(admin, code); + if (!verification.ok) { + await connection.rollback(); + return res.status(401).json({ message: 'Authenticator or recovery code is incorrect.' }); + } + await connection.query( + `UPDATE admin_users + SET mfa_secret_encrypted=NULL, mfa_setup_expires_at=NULL, mfa_enabled_at=NULL, + mfa_recovery_hashes=NULL, mfa_last_used_step=NULL + WHERE id=?`, + [admin.id], + ); + await connection.commit(); + return res.json({ user: publicAdmin({ ...admin, mfa_enabled_at: null }) }); + } catch (error) { + if (connection) await connection.rollback().catch(() => undefined); + next(error); + } finally { + if (connection) connection.release(); + } +}); + +router.get('/me', requireAdmin, (req, res) => { + res.json({ user: publicAdmin(req.admin) }); +}); + +router.post('/logout', (req, res) => { + clearSession(res); + res.json({ message: 'Signed out.' }); +}); + +router.post('/forgot-password', resetLimit, async (req, res, next) => { + try { + const email = normalizeEmail(req.body.email); + if (!email) return res.status(400).json({ message: 'Email is required.' }); + const [rows] = await db.query( + 'SELECT id, name, email FROM admin_users WHERE email=? AND status="active" LIMIT 1', + [email], + ); + const admin = rows[0]; + if (admin) { + const token = crypto.randomBytes(32).toString('hex'); + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + await db.query('DELETE FROM admin_password_resets WHERE admin_id=? OR expires_at < NOW()', [admin.id]); + await db.query( + 'INSERT INTO admin_password_resets (admin_id, token_hash, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? MINUTE))', + [admin.id, tokenHash, config.passwordResetMinutes], + ); + const delivery = await sendPasswordReset(admin, token); + if (delivery.status === 'failed') { + console.error('[auth/forgot-password]', delivery.error); + } + } + res.json({ message: GENERIC_RESET_MESSAGE }); + } catch (error) { + next(error); + } +}); + +router.post('/reset-password', resetLimit, async (req, res, next) => { + try { + const token = string(req.body.token, 256); + const password = String(req.body.password || ''); + const validationError = passwordError(password); + if (!token) return res.status(400).json({ message: 'Reset token is required.' }); + if (validationError) return res.status(400).json({ message: validationError }); + + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + const [rows] = await db.query( + `SELECT pr.id, pr.admin_id + FROM admin_password_resets pr + JOIN admin_users au ON au.id=pr.admin_id + WHERE pr.token_hash=? AND pr.used_at IS NULL AND pr.expires_at > NOW() AND au.status='active' + LIMIT 1`, + [tokenHash], + ); + const reset = rows[0]; + if (!reset) return res.status(400).json({ message: 'This reset link is invalid or expired.' }); + + const passwordHash = await bcrypt.hash(password, 12); + const connection = await db.getConnection(); + try { + await connection.beginTransaction(); + await connection.query( + 'UPDATE admin_users SET password_hash=?, failed_login_count=0, locked_until=NULL WHERE id=?', + [passwordHash, reset.admin_id], + ); + await connection.query('UPDATE admin_password_resets SET used_at=NOW() WHERE id=?', [reset.id]); + await connection.query('DELETE FROM admin_password_resets WHERE admin_id=? AND id<>?', [reset.admin_id, reset.id]); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + clearSession(res); + res.json({ message: 'Password updated. You can now sign in.' }); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/server/src/routes/earlyAccess.js b/server/src/routes/earlyAccess.js new file mode 100644 index 0000000..7e38394 --- /dev/null +++ b/server/src/routes/earlyAccess.js @@ -0,0 +1,75 @@ +const express = require('express'); +const rateLimit = require('express-rate-limit'); +const db = require('../db'); +const { getSetting } = require('../lib/settings'); +const { sendNewApplicationNotification } = require('../lib/mailer'); +const { validateApplication } = require('../lib/validation'); +const { verifyTurnstile } = require('../lib/turnstile'); + +const router = express.Router(); +const GENERIC_MESSAGE = 'Thanks. Your application has been received and will be reviewed.'; + +const submissionLimit = rateLimit({ + windowMs: 60 * 60 * 1000, + limit: 6, + standardHeaders: true, + legacyHeaders: false, + message: { message: 'Too many applications were submitted from this connection. Please try again later.' }, +}); + +router.post('/applications', submissionLimit, async (req, res, next) => { + try { + const result = validateApplication(req.body); + if (!result.ok) { + if (result.silent) return res.status(202).json({ message: GENERIC_MESSAGE }); + return res.status(400).json({ message: result.message }); + } + + const turnstile = await verifyTurnstile({ + token: String(req.body['cf-turnstile-response'] || ''), + remoteIp: req.ip, + }); + if (!turnstile.success) { + if (turnstile.reason === 'missing-secret' || turnstile.reason === 'unavailable') { + return res.status(503).json({ + message: 'Human verification is temporarily unavailable. Please try again later. / 人机验证暂时不可用,请稍后重试。', + }); + } + return res.status(403).json({ + message: 'Human verification failed. Please try again. / 人机验证失败,请重试。', + }); + } + + const settings = await getSetting('early_access'); + if (!settings.applications_open) { + return res.status(409).json({ message: 'PromptDock Early Access applications are currently closed.' }); + } + + const value = result.value; + const [insert] = await db.query( + `INSERT IGNORE INTO early_access_applications + (full_name, email, role, use_case, motivation, locale, cohort, consented_at) + VALUES (?, ?, ?, ?, ?, ?, ?, NOW())`, + [value.fullName, value.email, value.role, value.useCase, value.motivation, value.locale, settings.cohort], + ); + + if (insert.affectedRows === 1) { + const [rows] = await db.query( + 'SELECT * FROM early_access_applications WHERE id=? LIMIT 1', + [insert.insertId], + ); + const application = rows[0]; + setImmediate(() => { + sendNewApplicationNotification(application, settings).catch((error) => { + console.error('[early-access/notify]', error.message); + }); + }); + } + + return res.status(202).json({ message: GENERIC_MESSAGE }); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/server/src/routes/feedback.js b/server/src/routes/feedback.js new file mode 100644 index 0000000..a436bf8 --- /dev/null +++ b/server/src/routes/feedback.js @@ -0,0 +1,296 @@ +const crypto = require('crypto'); +const express = require('express'); +const rateLimit = require('express-rate-limit'); +const db = require('../db'); +const config = require('../config'); +const { sendFeedbackNotification } = require('../lib/mailer'); +const { getSetting } = require('../lib/settings'); +const { requireAdmin, requireManagementAdmin, requireOwner } = require('../middleware/auth'); +const { string } = require('../lib/validation'); + +const router = express.Router(); +const adminRouter = express.Router(); +const categories = new Set(['bug', 'idea', 'ux', 'performance', 'other']); +const submitLimit = rateLimit({ + windowMs: 60 * 60 * 1000, + limit: 10, + standardHeaders: true, + legacyHeaders: false, + message: { message: 'Too many feedback reports were submitted. Please try again later.' }, +}); + +const replyLimit = rateLimit({ + windowMs: 60 * 60 * 1000, + limit: 30, + standardHeaders: true, + legacyHeaders: false, + message: { message: 'Too many replies were submitted. Please try again later.' }, +}); + +function hashToken(token) { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +async function issueFeedbackToken(applicationId, revokeExisting = true) { + const token = crypto.randomBytes(32).toString('base64url'); + if (revokeExisting) { + await db.query( + `UPDATE feedback_access_tokens + SET revoked_at=NOW() + WHERE application_id=? AND revoked_at IS NULL`, + [applicationId], + ); + } + await db.query( + `INSERT INTO feedback_access_tokens (application_id, token_hash, expires_at) + VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 180 DAY))`, + [applicationId, hashToken(token)], + ); + return token; +} + +async function resolveToken(token) { + const clean = string(token, 160); + if (!/^[A-Za-z0-9_-]{40,80}$/.test(clean)) return null; + const [rows] = await db.query( + `SELECT fat.application_id, fat.expires_at, ea.full_name, ea.email, ea.cohort + FROM feedback_access_tokens fat + JOIN early_access_applications ea ON ea.id=fat.application_id + WHERE fat.token_hash=? AND fat.revoked_at IS NULL AND ea.status='approved' + AND (fat.expires_at IS NULL OR fat.expires_at > NOW()) + LIMIT 1`, + [hashToken(clean)], + ); + return rows[0] || null; +} + +async function ownerRecipient() { + const settings = await getSetting('early_access'); + if (settings.notify_email) return { name: 'CueGrove Owner', email: settings.notify_email }; + const [rows] = await db.query( + "SELECT name, email FROM admin_users WHERE role='owner' AND status='active' ORDER BY id LIMIT 1", + ); + return rows[0] || null; +} + +async function messagesForReports(reportIds) { + if (!reportIds.length) return new Map(); + const [rows] = await db.query( + `SELECT fm.id, fm.report_id, fm.author_type, fm.body, fm.created_at, au.name AS admin_name + FROM feedback_messages fm + LEFT JOIN admin_users au ON au.id=fm.admin_id + WHERE fm.report_id IN (?) + ORDER BY fm.created_at ASC, fm.id ASC`, + [reportIds], + ); + return rows.reduce((map, message) => { + const items = map.get(message.report_id) || []; + items.push(message); + map.set(message.report_id, items); + return map; + }, new Map()); +} + +async function reportsForApplication(applicationId) { + const [reports] = await db.query( + `SELECT * FROM feedback_reports + WHERE application_id=? + ORDER BY updated_at DESC, id DESC`, + [applicationId], + ); + const messages = await messagesForReports(reports.map((report) => report.id)); + return reports.map((report) => ({ ...report, messages: messages.get(report.id) || [] })); +} + +router.get('/:token([A-Za-z0-9_-]{40,80})', async (req, res, next) => { + try { + const access = await resolveToken(req.params.token); + if (!access) return res.status(404).json({ message: 'This feedback link is invalid or expired.' }); + const reports = await reportsForApplication(access.application_id); + res.json({ feedback: { name: access.full_name, cohort: access.cohort, reports } }); + } catch (error) { + next(error); + } +}); + +router.post('/:token([A-Za-z0-9_-]{40,80})', submitLimit, async (req, res, next) => { + try { + const access = await resolveToken(req.params.token); + if (!access) return res.status(404).json({ message: 'This feedback link is invalid or expired.' }); + + const category = string(req.body.category, 30); + const title = string(req.body.title, 160); + const details = string(req.body.details, 8000); + const steps = string(req.body.steps, 4000); + const expected = string(req.body.expected, 4000); + const actual = string(req.body.actual, 4000); + const device = string(req.body.device, 120); + const macosVersion = string(req.body.macos_version, 80); + const appBuild = string(req.body.app_build, 80); + + if (!categories.has(category)) return res.status(400).json({ message: 'Choose a feedback category.' }); + if (title.length < 3 || details.length < 10) { + return res.status(400).json({ message: 'Please provide a title and at least a few details.' }); + } + + const [result] = await db.query( + `INSERT INTO feedback_reports + (application_id, category, title, details, steps, expected, actual, device, macos_version, app_build) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [access.application_id, category, title, details, steps || null, expected || null, actual || null, device || null, macosVersion || null, appBuild || null], + ); + const owner = await ownerRecipient(); + const delivery = owner ? await sendFeedbackNotification({ + applicationId: access.application_id, + to: owner.email, + actor: 'tester', + locale: 'zh', + recipientName: owner.name, + reportTitle: title, + actionUrl: `${config.siteUrl}/admin/feedback?report=${result.insertId}`, + isNewReport: true, + }) : { status: 'skipped' }; + res.status(201).json({ message: 'Thanks — your feedback was received.', email_status: delivery.status }); + } catch (error) { + next(error); + } +}); + +router.post('/:token([A-Za-z0-9_-]{40,80})/:id/replies', replyLimit, async (req, res, next) => { + try { + const access = await resolveToken(req.params.token); + if (!access) return res.status(404).json({ message: 'This feedback link is invalid or expired.' }); + const id = Number(req.params.id); + const body = string(req.body.body, 4000); + if (!Number.isInteger(id) || id < 1 || body.length < 1) { + return res.status(400).json({ message: 'Reply text is required.' }); + } + const [reports] = await db.query( + 'SELECT id, application_id, title FROM feedback_reports WHERE id=? AND application_id=? LIMIT 1', + [id, access.application_id], + ); + const report = reports[0]; + if (!report) return res.status(404).json({ message: 'Feedback report not found.' }); + await db.query( + "INSERT INTO feedback_messages (report_id, author_type, body) VALUES (?, 'tester', ?)", + [id, body], + ); + await db.query("UPDATE feedback_reports SET status='new', updated_at=NOW() WHERE id=?", [id]); + const owner = await ownerRecipient(); + const delivery = owner ? await sendFeedbackNotification({ + applicationId: access.application_id, + to: owner.email, + actor: 'tester', + locale: 'zh', + recipientName: owner.name, + reportTitle: report.title, + actionUrl: `${config.siteUrl}/admin/feedback?report=${id}`, + isNewReport: false, + }) : { status: 'skipped' }; + res.status(201).json({ message: 'Reply sent.', email_status: delivery.status }); + } catch (error) { + next(error); + } +}); + +adminRouter.use(requireAdmin, requireManagementAdmin, requireOwner); + +adminRouter.get('/', async (req, res, next) => { + try { + const status = ['new', 'triaged', 'resolved', 'all'].includes(req.query.status) ? req.query.status : 'new'; + const keyword = string(req.query.keyword, 100); + const conditions = []; + const params = []; + if (status !== 'all') { conditions.push('fr.status=?'); params.push(status); } + if (keyword) { + const match = `%${keyword}%`; + conditions.push('(ea.full_name LIKE ? OR ea.email LIKE ? OR fr.title LIKE ? OR fr.details LIKE ?)'); + params.push(match, match, match, match); + } + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + const [rows] = await db.query( + `SELECT fr.*, ea.full_name, ea.email, ea.cohort, + (SELECT COUNT(*) FROM feedback_messages fm WHERE fm.report_id=fr.id) AS message_count + FROM feedback_reports fr JOIN early_access_applications ea ON ea.id=fr.application_id + ${where} ORDER BY fr.created_at DESC LIMIT 300`, + params, + ); + res.json({ reports: rows }); + } catch (error) { + next(error); + } +}); + +adminRouter.get('/:id', async (req, res, next) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id < 1) return res.status(400).json({ message: 'Feedback report id is invalid.' }); + const [rows] = await db.query( + `SELECT fr.*, ea.full_name, ea.email, ea.locale, ea.cohort + FROM feedback_reports fr + JOIN early_access_applications ea ON ea.id=fr.application_id + WHERE fr.id=? LIMIT 1`, + [id], + ); + if (!rows[0]) return res.status(404).json({ message: 'Feedback report not found.' }); + const messages = await messagesForReports([id]); + res.json({ report: { ...rows[0], messages: messages.get(id) || [] } }); + } catch (error) { + next(error); + } +}); + +adminRouter.post('/:id/replies', async (req, res, next) => { + try { + const id = Number(req.params.id); + const body = string(req.body.body, 4000); + if (!Number.isInteger(id) || id < 1 || body.length < 1) { + return res.status(400).json({ message: 'Reply text is required.' }); + } + const [rows] = await db.query( + `SELECT fr.id, fr.application_id, fr.title, ea.full_name, ea.email, ea.locale + FROM feedback_reports fr + JOIN early_access_applications ea ON ea.id=fr.application_id + WHERE fr.id=? LIMIT 1`, + [id], + ); + const report = rows[0]; + if (!report) return res.status(404).json({ message: 'Feedback report not found.' }); + const replyToken = await issueFeedbackToken(report.application_id, false); + await db.query( + "INSERT INTO feedback_messages (report_id, author_type, admin_id, body) VALUES (?, 'developer', ?, ?)", + [id, req.admin.id, body], + ); + await db.query('UPDATE feedback_reports SET updated_at=NOW() WHERE id=?', [id]); + const delivery = await sendFeedbackNotification({ + applicationId: report.application_id, + to: report.email, + actor: 'developer', + locale: report.locale, + recipientName: report.full_name, + reportTitle: report.title, + actionUrl: `${config.siteUrl}/feedback/${replyToken}#report-${id}`, + isNewReport: false, + }); + res.status(201).json({ message: 'Reply sent.', email_status: delivery.status }); + } catch (error) { + next(error); + } +}); + +adminRouter.patch('/:id', async (req, res, next) => { + try { + const id = Number(req.params.id); + const status = string(req.body.status, 20); + if (!Number.isInteger(id) || id < 1 || !['new', 'triaged', 'resolved'].includes(status)) { + return res.status(400).json({ message: 'Feedback status is invalid.' }); + } + const [result] = await db.query('UPDATE feedback_reports SET status=? WHERE id=?', [status, id]); + if (!result.affectedRows) return res.status(404).json({ message: 'Feedback report not found.' }); + res.json({ message: 'Feedback status updated.' }); + } catch (error) { + next(error); + } +}); + +module.exports = { router, adminRouter, issueFeedbackToken }; diff --git a/server/src/routes/releases.js b/server/src/routes/releases.js new file mode 100644 index 0000000..eaa1522 --- /dev/null +++ b/server/src/routes/releases.js @@ -0,0 +1,19 @@ +const express = require('express'); +const { RELEASE_FILE_NAME, releaseFilePath, releaseInfo } = require('../lib/releaseStorage'); + +const router = express.Router(); + +// Intentionally public: approved testers receive this unlisted direct URL. +// The URL is a distribution convenience, not an access-control boundary. +router.get('/promptdock', async (_req, res, next) => { + try { + const info = await releaseInfo(); + if (!info.available) return res.status(404).json({ message: 'PromptDock is not available for download yet.' }); + res.set('Cache-Control', 'private, no-store'); + return res.download(releaseFilePath(), RELEASE_FILE_NAME); + } catch (error) { + return next(error); + } +}); + +module.exports = router; diff --git a/server/src/routes/rollAccess.js b/server/src/routes/rollAccess.js new file mode 100644 index 0000000..ff4ff2f --- /dev/null +++ b/server/src/routes/rollAccess.js @@ -0,0 +1,316 @@ +const express = require('express'); +const rateLimit = require('express-rate-limit'); +const jwt = require('jsonwebtoken'); +const { randomUUID } = require('crypto'); +const db = require('../db'); +const config = require('../config'); +const { optionalAdminSession, requireAdmin, requireRollKeyIssuer } = require('../middleware/auth'); +const { + accessKeyExpiry, + generateAccessKey, + hashAccessKey, + normalizeAccessKeyId, + normalizeAccessKeyIds, + rollAccessIdFromPayload, +} = require('../lib/rollAccess'); + +const publicRouter = express.Router(); +const adminRouter = express.Router(); +const COOKIE_NAME = `${config.cookie.name}_roll`; + +publicRouter.use((_req, res, next) => { + res.set('Cache-Control', 'no-store'); + next(); +}); + +adminRouter.use((_req, res, next) => { + res.set('Cache-Control', 'no-store'); + next(); +}); + +const verifyLimit = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 12, + standardHeaders: true, + legacyHeaders: false, + message: { message: 'Too many access-key attempts. Please try again later.' }, +}); + +function ensureJwtConfigured() { + if (!config.jwtSecret || config.jwtSecret.length < 32) { + const error = new Error('JWT_SECRET must contain at least 32 characters.'); + error.code = 'AUTH_NOT_CONFIGURED'; + throw error; + } +} + +function cookieOptions(maxAge) { + const options = { + httpOnly: true, + secure: config.cookie.secure, + sameSite: 'lax', + path: '/', + }; + if (maxAge !== undefined) options.maxAge = maxAge; + if (config.cookie.domain) options.domain = config.cookie.domain; + return options; +} + +function clearAccessSession(res) { + res.clearCookie(COOKIE_NAME, cookieOptions()); +} + +function publicAccessKey(row) { + return { + id: row.id, + key_prefix: row.key_prefix, + created_by_name: row.created_by_name || null, + expires_at: row.expires_at, + revoked_at: row.revoked_at, + last_used_at: row.last_used_at, + use_count: Number(row.use_count || 0), + created_at: row.created_at, + }; +} + +async function inTransaction(work) { + const connection = await db.getConnection(); + try { + await connection.beginTransaction(); + const result = await work(connection); + await connection.commit(); + return result; + } catch (error) { + await connection.rollback().catch(() => undefined); + throw error; + } finally { + connection.release(); + } +} + +async function recordKeyAudit(connection, entries) { + if (!entries.length) return; + const placeholders = entries.map(() => '(?, ?, ?, ?, ?)').join(','); + const values = entries.flatMap((entry) => [ + entry.keyId, + entry.keyPrefix, + entry.adminId, + entry.action, + entry.batchId || null, + ]); + await connection.query( + `INSERT INTO roll_access_key_audit (key_id, key_prefix, admin_id, action, batch_id) + VALUES ${placeholders}`, + values, + ); +} + +publicRouter.post('/verify', verifyLimit, async (req, res, next) => { + try { + ensureJwtConfigured(); + const accessKey = String(req.body.access_key || '').trim(); + if (!accessKey || accessKey.length > 128) { + return res.status(400).json({ message: 'A valid access key is required.' }); + } + const [rows] = await db.query( + `SELECT id, expires_at + FROM roll_access_keys + WHERE key_hash=? AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP() + LIMIT 1`, + [hashAccessKey(accessKey)], + ); + const record = rows[0]; + if (!record) return res.status(401).json({ message: 'This access key is invalid or expired.' }); + + const expiresAt = new Date(record.expires_at); + const maxAge = Math.max(1, expiresAt.getTime() - Date.now()); + const token = jwt.sign( + { id: Number(record.id), purpose: 'roll-access' }, + config.jwtSecret, + { expiresIn: Math.max(1, Math.ceil(maxAge / 1000)) }, + ); + res.cookie(COOKIE_NAME, token, cookieOptions(maxAge)); + await db.query( + 'UPDATE roll_access_keys SET last_used_at=UTC_TIMESTAMP(), use_count=use_count+1 WHERE id=?', + [record.id], + ); + return res.json({ administrator: false, expires_at: expiresAt.toISOString() }); + } catch (error) { + if (error.code === 'AUTH_NOT_CONFIGURED') return res.status(503).json({ message: error.message }); + next(error); + } +}); + +publicRouter.get('/session', async (req, res, next) => { + try { + ensureJwtConfigured(); + const admin = await optionalAdminSession(req); + if (admin) return res.json({ administrator: true, expires_at: null }); + const token = req.cookies?.[COOKIE_NAME]; + if (!token) return res.status(401).json({ message: 'A tool access key is required.' }); + const payload = jwt.verify(token, config.jwtSecret); + const accessKeyId = rollAccessIdFromPayload(payload); + if (!accessKeyId) { + const error = new Error('Invalid access session.'); + error.code = 'INVALID_ROLL_SESSION'; + throw error; + } + const [rows] = await db.query( + `SELECT expires_at + FROM roll_access_keys + WHERE id=? AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP() + LIMIT 1`, + [accessKeyId], + ); + if (!rows[0]) { + clearAccessSession(res); + return res.status(401).json({ message: 'This access session has expired.' }); + } + return res.json({ administrator: false, expires_at: new Date(rows[0].expires_at).toISOString() }); + } catch (error) { + clearAccessSession(res); + if (error.code === 'AUTH_NOT_CONFIGURED') return res.status(503).json({ message: error.message }); + if (error.code === 'INVALID_ROLL_SESSION' || ['JsonWebTokenError', 'TokenExpiredError', 'NotBeforeError'].includes(error.name)) { + return res.status(401).json({ message: 'This access session is invalid or expired.' }); + } + return next(error); + } +}); + +publicRouter.post('/logout', (_req, res) => { + clearAccessSession(res); + res.json({ message: 'Tool access ended.' }); +}); + +adminRouter.use(requireAdmin, requireRollKeyIssuer); + +adminRouter.get('/', async (_req, res, next) => { + try { + const [rows] = await db.query( + `SELECT rak.*, au.name AS created_by_name + FROM roll_access_keys rak + LEFT JOIN admin_users au ON au.id=rak.created_by + ORDER BY rak.id DESC + LIMIT 100`, + ); + res.json({ access_keys: rows.map(publicAccessKey) }); + } catch (error) { + next(error); + } +}); + +adminRouter.post('/', async (req, res, next) => { + try { + const accessKey = generateAccessKey(); + const expiresAt = accessKeyExpiry(); + const keyPrefix = accessKey.slice(0, 11); + const record = await inTransaction(async (connection) => { + const [result] = await connection.query( + `INSERT INTO roll_access_keys (key_hash, key_prefix, created_by, expires_at) + VALUES (?, ?, ?, ?)`, + [hashAccessKey(accessKey), keyPrefix, req.admin.id, expiresAt], + ); + await recordKeyAudit(connection, [{ + keyId: result.insertId, + keyPrefix, + adminId: req.admin.id, + action: 'create', + }]); + const [rows] = await connection.query( + `SELECT rak.*, au.name AS created_by_name + FROM roll_access_keys rak + LEFT JOIN admin_users au ON au.id=rak.created_by + WHERE rak.id=? LIMIT 1`, + [result.insertId], + ); + return rows[0]; + }); + res.status(201).json({ access_key: accessKey, record: publicAccessKey(record) }); + } catch (error) { + next(error); + } +}); + +adminRouter.post('/:id/revoke', async (req, res, next) => { + try { + const id = normalizeAccessKeyId(req.params.id); + if (!id) return res.status(400).json({ message: 'Access-key id is invalid.' }); + const revoked = await inTransaction(async (connection) => { + const [rows] = await connection.query( + 'SELECT id, key_prefix FROM roll_access_keys WHERE id=? AND revoked_at IS NULL FOR UPDATE', + [id], + ); + if (!rows[0]) return false; + await connection.query('UPDATE roll_access_keys SET revoked_at=UTC_TIMESTAMP() WHERE id=?', [id]); + await recordKeyAudit(connection, [{ + keyId: id, + keyPrefix: rows[0].key_prefix, + adminId: req.admin.id, + action: 'revoke', + }]); + return true; + }); + if (!revoked) return res.status(404).json({ message: 'Access key not found or already revoked.' }); + res.json({ message: 'Access key revoked.' }); + } catch (error) { + next(error); + } +}); + +adminRouter.delete('/', async (req, res, next) => { + try { + const ids = normalizeAccessKeyIds(req.body.ids); + if (!ids) return res.status(400).json({ message: 'Select between 1 and 100 valid access keys.' }); + const placeholders = ids.map(() => '?').join(','); + const deleted = await inTransaction(async (connection) => { + const [rows] = await connection.query( + `SELECT id, key_prefix FROM roll_access_keys WHERE id IN (${placeholders}) FOR UPDATE`, + ids, + ); + if (!rows.length) return 0; + const batchId = randomUUID(); + await recordKeyAudit(connection, rows.map((row) => ({ + keyId: row.id, + keyPrefix: row.key_prefix, + adminId: req.admin.id, + action: 'bulk_delete', + batchId, + }))); + const rowIds = rows.map((row) => row.id); + const rowPlaceholders = rowIds.map(() => '?').join(','); + const [result] = await connection.query(`DELETE FROM roll_access_keys WHERE id IN (${rowPlaceholders})`, rowIds); + return result.affectedRows; + }); + res.json({ deleted }); + } catch (error) { + next(error); + } +}); + +adminRouter.delete('/:id', async (req, res, next) => { + try { + const id = normalizeAccessKeyId(req.params.id); + if (!id) return res.status(400).json({ message: 'Access-key id is invalid.' }); + const deleted = await inTransaction(async (connection) => { + const [rows] = await connection.query( + 'SELECT id, key_prefix FROM roll_access_keys WHERE id=? FOR UPDATE', + [id], + ); + if (!rows[0]) return false; + await recordKeyAudit(connection, [{ + keyId: id, + keyPrefix: rows[0].key_prefix, + adminId: req.admin.id, + action: 'delete', + }]); + await connection.query('DELETE FROM roll_access_keys WHERE id=?', [id]); + return true; + }); + if (!deleted) return res.status(404).json({ message: 'Access key not found.' }); + res.json({ deleted: 1 }); + } catch (error) { + next(error); + } +}); + +module.exports = { adminRouter, publicRouter }; diff --git a/server/src/scripts/create-admin.js b/server/src/scripts/create-admin.js new file mode 100644 index 0000000..dcf6a71 --- /dev/null +++ b/server/src/scripts/create-admin.js @@ -0,0 +1,40 @@ +const bcrypt = require('bcryptjs'); +const db = require('../db'); +const { isEmail, normalizeEmail, passwordError, string } = require('../lib/validation'); + +function argument(name) { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 ? process.argv[index + 1] : ''; +} + +async function run() { + const name = string(argument('name'), 80); + const email = normalizeEmail(argument('email')); + const role = argument('role') === 'admin' ? 'admin' : 'owner'; + const password = process.env.CUEGROVE_ADMIN_PASSWORD || ''; + + if (!name || !isEmail(email)) { + throw new Error('Usage: npm run create-admin -- --name "Owner" --email owner@example.com [--role owner|admin]'); + } + const invalidPassword = passwordError(password); + if (invalidPassword) { + throw new Error(`Set CUEGROVE_ADMIN_PASSWORD for this one command. ${invalidPassword}`); + } + + const passwordHash = await bcrypt.hash(password, 12); + await db.query( + `INSERT INTO admin_users (name, email, password_hash, role, status) + VALUES (?, ?, ?, ?, 'active') + ON DUPLICATE KEY UPDATE name=VALUES(name), password_hash=VALUES(password_hash), role=VALUES(role), status='active', failed_login_count=0, locked_until=NULL`, + [name, email, passwordHash, role], + ); + delete process.env.CUEGROVE_ADMIN_PASSWORD; + console.log(`[create-admin] ${role} account ready for ${email}`); + await db.end(); +} + +run().catch(async (error) => { + console.error('[create-admin] failed:', error.message); + await db.end().catch(() => undefined); + process.exit(1); +}); diff --git a/server/src/scripts/migrate.js b/server/src/scripts/migrate.js new file mode 100644 index 0000000..7143a06 --- /dev/null +++ b/server/src/scripts/migrate.js @@ -0,0 +1,49 @@ +const fs = require('fs/promises'); +const path = require('path'); +const mysql = require('mysql2/promise'); +const config = require('../config'); + +async function run() { + const connection = await mysql.createConnection({ + ...config.db, + multipleStatements: true, + charset: 'utf8mb4', + timezone: 'Z', + }); + const directory = path.resolve(__dirname, '..', '..', 'database', 'migrations'); + const files = (await fs.readdir(directory)).filter((name) => name.endsWith('.sql')).sort(); + await connection.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + migration_name VARCHAR(255) NOT NULL PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + + for (const file of files) { + const [rows] = await connection.query( + 'SELECT migration_name FROM schema_migrations WHERE migration_name=? LIMIT 1', + [file], + ); + if (rows[0]) { + console.log(`[migrate] already applied: ${file}`); + continue; + } + const sql = await fs.readFile(path.join(directory, file), 'utf8'); + await connection.beginTransaction(); + try { + await connection.query(sql); + await connection.query('INSERT INTO schema_migrations (migration_name) VALUES (?)', [file]); + await connection.commit(); + console.log(`[migrate] applied: ${file}`); + } catch (error) { + await connection.rollback(); + throw error; + } + } + await connection.end(); +} + +run().catch((error) => { + console.error('[migrate] failed:', error.message); + process.exit(1); +}); diff --git a/server/src/scripts/reset-mfa.js b/server/src/scripts/reset-mfa.js new file mode 100644 index 0000000..dca9180 --- /dev/null +++ b/server/src/scripts/reset-mfa.js @@ -0,0 +1,34 @@ +const db = require('../db'); +const { isEmail, normalizeEmail } = require('../lib/validation'); + +function argument(name) { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 ? process.argv[index + 1] : ''; +} + +async function run() { + const email = normalizeEmail(argument('email')); + if (!isEmail(email)) { + throw new Error('Usage: pnpm reset-mfa -- --email owner@example.com'); + } + if (process.env.CUEGROVE_RESET_MFA !== 'YES') { + throw new Error('Set CUEGROVE_RESET_MFA=YES for this one emergency recovery command.'); + } + const [result] = await db.query( + `UPDATE admin_users + SET mfa_secret_encrypted=NULL, mfa_setup_expires_at=NULL, mfa_enabled_at=NULL, + mfa_recovery_hashes=NULL, mfa_last_used_step=NULL + WHERE email=?`, + [email], + ); + if (!result.affectedRows) throw new Error(`No administrator account found for ${email}.`); + delete process.env.CUEGROVE_RESET_MFA; + console.log(`[reset-mfa] multi-factor authentication removed for ${email}`); + await db.end(); +} + +run().catch(async (error) => { + console.error('[reset-mfa] failed:', error.message); + await db.end().catch(() => undefined); + process.exit(1); +}); diff --git a/server/test/app.test.js b/server/test/app.test.js new file mode 100644 index 0000000..7d98295 --- /dev/null +++ b/server/test/app.test.js @@ -0,0 +1,144 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const jwt = require('jsonwebtoken'); +const { createApp } = require('../src/app'); +const config = require('../src/config'); +const db = require('../src/db'); +const { adminRouter: feedbackAdminRouter } = require('../src/routes/feedback'); + +async function startServer(t) { + const server = createApp().listen(0, '127.0.0.1'); + t.after(() => new Promise((resolve) => server.close(resolve))); + await new Promise((resolve) => server.once('listening', resolve)); + return `http://127.0.0.1:${server.address().port}`; +} + +test('feedback administration explicitly keeps the management-account gate', () => { + const middlewareNames = feedbackAdminRouter.stack + .filter((layer) => !layer.route) + .map((layer) => layer.handle.name); + assert.deepEqual(middlewareNames.slice(0, 3), ['requireAdmin', 'requireManagementAdmin', 'requireOwner']); +}); + +test('applies a restrictive CSP to API responses', async (t) => { + const baseUrl = await startServer(t); + const response = await fetch(`${baseUrl}/api/health`); + + assert.equal(response.status, 200); + assert.match(response.headers.get('content-security-policy') || '', /default-src 'none'/); + assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); +}); + +test('rejects state-changing requests without an allowed origin', async (t) => { + const baseUrl = await startServer(t); + const rejected = await fetch(`${baseUrl}/api/roll-access/logout`, { method: 'POST' }); + assert.equal(rejected.status, 403); + + const allowed = await fetch(`${baseUrl}/api/roll-access/logout`, { + method: 'POST', + headers: { Origin: config.siteUrl }, + }); + assert.equal(allowed.status, 200); +}); + +test('clears a roll session after its key is revoked or deleted', async (t) => { + const originalSecret = config.jwtSecret; + const originalQuery = db.query; + config.jwtSecret = 'test-secret-that-is-at-least-32-characters'; + db.query = async () => [[]]; + t.after(() => { + config.jwtSecret = originalSecret; + db.query = originalQuery; + }); + + const baseUrl = await startServer(t); + const token = jwt.sign({ id: 3, purpose: 'roll-access' }, config.jwtSecret, { expiresIn: '5m' }); + const response = await fetch(`${baseUrl}/api/roll-access/session`, { + headers: { Cookie: `${config.cookie.name}_roll=${token}` }, + }); + + assert.equal(response.status, 401); + assert.match(response.headers.get('set-cookie') || '', new RegExp(`${config.cookie.name}_roll=;`)); +}); + +test('records every access-key mutation without sensitive key material', async (t) => { + const originalSecret = config.jwtSecret; + const originalQuery = db.query; + const originalGetConnection = db.getConnection; + config.jwtSecret = 'test-secret-that-is-at-least-32-characters'; + const auditWrites = []; + let commits = 0; + db.query = async () => [[{ + id: 1, + name: 'Owner', + email: 'owner@example.com', + role: 'owner', + status: 'active', + can_issue_roll_keys: 0, + mfa_enabled_at: null, + }]]; + db.getConnection = async () => ({ + async beginTransaction() {}, + async commit() { commits += 1; }, + async rollback() {}, + release() {}, + async query(sql, values = []) { + const compact = sql.replace(/\s+/g, ' ').trim(); + if (compact.startsWith('INSERT INTO roll_access_keys')) return [{ insertId: 12 }]; + if (compact.startsWith('INSERT INTO roll_access_key_audit')) { + auditWrites.push(values); + return [{ affectedRows: values.length / 5 }]; + } + if (compact.startsWith('SELECT rak.*')) return [[{ + id: 12, + key_prefix: 'CG-created', + created_by_name: 'Owner', + expires_at: new Date(Date.now() + 60_000), + revoked_at: null, + last_used_at: null, + use_count: 0, + created_at: new Date(), + }]]; + if (compact.includes('revoked_at IS NULL FOR UPDATE')) return [[{ id: 12, key_prefix: 'CG-revoke' }]]; + if (compact.includes('WHERE id=? FOR UPDATE')) return [[{ id: 12, key_prefix: 'CG-delete' }]]; + if (compact.includes('WHERE id IN') && compact.endsWith('FOR UPDATE')) { + return [[{ id: 13, key_prefix: 'CG-bulk-one' }, { id: 14, key_prefix: 'CG-bulk-two' }]]; + } + if (compact.startsWith('UPDATE roll_access_keys')) return [{ affectedRows: 1 }]; + if (compact.startsWith('DELETE FROM roll_access_keys')) return [{ affectedRows: values.length }]; + throw new Error(`Unexpected audit-test query: ${compact}`); + }, + }); + t.after(() => { + config.jwtSecret = originalSecret; + db.query = originalQuery; + db.getConnection = originalGetConnection; + }); + + const baseUrl = await startServer(t); + const adminToken = jwt.sign({ id: 1, purpose: 'admin' }, config.jwtSecret, { expiresIn: '5m' }); + const headers = { + Origin: config.siteUrl, + Cookie: `${config.cookie.name}=${adminToken}`, + 'Content-Type': 'application/json', + }; + const responses = []; + responses.push(await fetch(`${baseUrl}/api/admin/roll-access-keys`, { method: 'POST', headers })); + responses.push(await fetch(`${baseUrl}/api/admin/roll-access-keys/12/revoke`, { method: 'POST', headers })); + responses.push(await fetch(`${baseUrl}/api/admin/roll-access-keys/12`, { method: 'DELETE', headers })); + responses.push(await fetch(`${baseUrl}/api/admin/roll-access-keys`, { + method: 'DELETE', + headers, + body: JSON.stringify({ ids: [13, 14] }), + })); + assert.deepEqual(responses.map((response) => response.status), [201, 200, 200, 200]); + assert.equal(commits, 4); + assert.deepEqual(auditWrites.map((values) => values[3]), ['create', 'revoke', 'delete', 'bulk_delete']); + assert.equal(auditWrites[3][3], 'bulk_delete'); + assert.equal(auditWrites[3][8], 'bulk_delete'); + assert.equal(auditWrites[3][4], auditWrites[3][9]); + const created = await responses[0].json(); + const auditStrings = auditWrites.flat().filter((value) => typeof value === 'string'); + assert.ok(!auditStrings.includes(created.access_key)); + assert.ok(auditStrings.every((value) => !/^[a-f0-9]{64}$/i.test(value))); +}); diff --git a/server/test/mailer.test.js b/server/test/mailer.test.js new file mode 100644 index 0000000..489a8f7 --- /dev/null +++ b/server/test/mailer.test.js @@ -0,0 +1,131 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const config = require('../src/config'); +const { + buildFeedbackNotification, + buildPasswordResetUrl, + buildDecisionMessage, + escapeHtml, + mailReady, + smtpTransportOptions, +} = require('../src/lib/mailer'); + +const settings = { + download_url: 'https://cuegrove.example/download', + feedback_url: 'https://cuegrove.example/feedback', +}; + +test('builds the fixed Chinese approval subject and download action', () => { + const message = buildDecisionMessage({ + id: 1, + full_name: '小林', + email: 'lin@example.com', + locale: 'zh', + status: 'approved', + applicant_message: '', + }, settings); + assert.equal(message.subject, '[CueGrove] PromptDock Early Access 申请已通过'); + assert.match(message.html, /下载 PromptDock/); + assert.match(message.text, /https:\/\/cuegrove\.example\/download/); +}); + +test('builds the fixed English rejection subject without download URL', () => { + const message = buildDecisionMessage({ + id: 2, + full_name: 'Lin', + email: 'lin@example.com', + locale: 'en', + status: 'rejected', + applicant_message: 'Thank you for your time.', + }, settings); + assert.equal(message.subject, '[CueGrove] Your PromptDock Early Access application'); + assert.doesNotMatch(message.html, />Download PromptDock { + assert.equal(escapeHtml(''), '<img src=x onerror=alert(1)>'); + const message = buildDecisionMessage({ + id: 3, + full_name: '', + email: 'lin@example.com', + locale: 'en', + status: 'approved', + applicant_message: 'hello', + }, settings); + assert.doesNotMatch(message.html, /', + actionUrl: 'https://cuegrove.example/feedback/private-token', + }); + assert.match(message.subject, /开发者回复/); + assert.match(message.html, /查看开发者回复/); + assert.doesNotMatch(message.html, /