diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..72815c8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +.git +.github +.agents +.codex +.vscode +.idea + +node_modules +.pnpm-store +dist +.data +coverage +playwright-report +test-results + +.env +.env.* +*.log +pnpm-debug.log* + +docs +e2e +server/test +README.md +README.en.md +SECURITY.md +docker-compose*.yml diff --git a/.env.example b/.env.example index 8f41eef..ccf3517 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,30 @@ -# REST API origin. Do not include a trailing slash. -VITE_API_BASE_URL=http://localhost:7777 +# The browser uses the same-origin API path in development and production. +# Override only when a reverse proxy exposes this service at another path. +VITE_API_BASE_URL=/api/v1 -# Full endpoint used by the forum/keyword-processing feature. -VITE_FORUM_API_URL=http://localhost:5002/process_words +# API server settings. The database and uploads stay local unless explicitly changed. +HOST=127.0.0.1 +PORT=7777 +DATABASE_PATH=.data/platform.db +UPLOAD_DIR=.data/uploads +# Per-account quota defaults to 250 MiB; the platform cap defaults to 5 GiB. +UPLOAD_OWNER_QUOTA_BYTES=262144000 +UPLOAD_TOTAL_QUOTA_BYTES=5368709120 +UPLOAD_MAX_CONCURRENT=4 +APP_ORIGIN=http://localhost:5173 +SESSION_TTL_HOURS=12 +VERIFICATION_CODE_SECRET= -# WebSocket endpoint used by chat and live-class signaling. -VITE_WEBSOCKET_URL=ws://localhost:7788/websocket +# One-time production administrator bootstrap (pnpm admin:bootstrap). +ADMIN_EMAIL= +ADMIN_PASSWORD= +ADMIN_DISPLAY_NAME=平台管理员 + +# Optional external adapters. Core demo flows work without these values. +SMTP_URL= +MAIL_FROM=International Chinese Platform +AI_API_URL= +AI_API_KEY= +TURN_URL= +TURN_USERNAME= +TURN_CREDENTIAL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb8bfd1..d9e67ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,23 +23,104 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@v4 with: - version: 8.15.9 + version: 11.9.0 run_install: false - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Lint - run: pnpm lint:check + - name: Validate source, API and production build + run: pnpm check + env: + NODE_ENV: test + CI: true - - name: Check formatting - run: pnpm format:check + - name: Upload production bundle + uses: actions/upload-artifact@v4 + with: + name: international-chinese-platform-dist + path: dist + if-no-files-found: error + retention-days: 7 + + e2e: + name: Cross-role browser E2E + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@v4 - - name: Build + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.9.0 + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: e2e/requirements.txt + + - name: Install application dependencies + run: pnpm install --frozen-lockfile + + - name: Build production application run: pnpm build + + - name: Install browser test dependencies + run: | + python -m pip install --requirement e2e/requirements.txt + python -m playwright install --with-deps chromium + + - name: Start isolated production service + env: + NODE_ENV: production + SEED_ON_START: 'true' + SECURE_COOKIES: 'false' + APP_ORIGIN: http://localhost:7777 + VERIFICATION_CODE_SECRET: e2e-only-verification-secret-32-characters + DATABASE_PATH: .data/e2e.db + run: | + mkdir -p test-results/e2e .data + rm -f .data/e2e.db .data/e2e.db-shm .data/e2e.db-wal + pnpm start > test-results/e2e/service.log 2>&1 & + echo $! > test-results/e2e/service.pid + + - name: Run cross-role browser workflows + env: + E2E_BASE_URL: http://localhost:7777 + run: python e2e/test_workflows.py + + - name: Upload E2E failure artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: international-chinese-platform-e2e-failure + path: | + test-results/e2e + .data/e2e.db + if-no-files-found: warn + retention-days: 7 + + - name: Stop production service + if: always() + run: | + if [ -f test-results/e2e/service.pid ]; then + kill "$(cat test-results/e2e/service.pid)" || true + fi diff --git a/.gitignore b/.gitignore index 8ee54e8..34c45d1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,11 +8,20 @@ pnpm-debug.log* lerna-debug.log* node_modules +.pnpm-store/ .DS_Store dist dist-ssr coverage *.local +.env +.env.* +!.env.example +.data/ +playwright-report/ +test-results/ +__pycache__/ +*.py[cod] /cypress/videos/ /cypress/screenshots/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f1c0374 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:24-bookworm-slim AS build + +ARG PNPM_VERSION=11.9.0 +ARG VITE_API_BASE_URL=/api/v1 + +ENV PNPM_HOME=/pnpm \ + PATH=/pnpm:$PATH \ + VITE_API_BASE_URL=${VITE_API_BASE_URL} + +RUN corepack enable \ + && corepack prepare "pnpm@${PNPM_VERSION}" --activate \ + && apt-get update \ + && apt-get install --yes --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ + pnpm install --frozen-lockfile + +COPY . . +RUN pnpm build && pnpm prune --prod + + +FROM node:24-bookworm-slim AS runtime + +LABEL org.opencontainers.image.title="International Chinese Platform" \ + org.opencontainers.image.description="Self-contained international Chinese education platform" \ + org.opencontainers.image.source="https://github.com/computersciencefreshmen/International_Chinese_Platform" + +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=7777 \ + DATABASE_PATH=/app/data/platform.db \ + UPLOAD_DIR=/app/uploads \ + DIST_DIR=/app/dist \ + SEED_ON_START=false + +RUN groupadd --system --gid 10001 platform \ + && useradd --system --uid 10001 --gid platform --home-dir /app --shell /usr/sbin/nologin platform \ + && mkdir -p /app/data /app/uploads \ + && chown -R platform:platform /app + +WORKDIR /app + +COPY --from=build --chown=platform:platform /app/package.json ./package.json +COPY --from=build --chown=platform:platform /app/node_modules ./node_modules +COPY --from=build --chown=platform:platform /app/server ./server +COPY --from=build --chown=platform:platform /app/dist ./dist + +USER 10001:10001 + +EXPOSE 7777 +VOLUME ["/app/data", "/app/uploads"] +STOPSIGNAL SIGTERM + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:7777/api/v1/ready').then((response) => { if (!response.ok) process.exit(1) }).catch(() => process.exit(1))"] + +CMD ["node", "server/index.js"] diff --git a/README.en.md b/README.en.md index cf2fd94..4034b80 100644 --- a/README.en.md +++ b/README.en.md @@ -1,171 +1,198 @@
- International Chinese Platform logo + International Chinese Platform logo -

🌏 International Chinese Platform

+

International Chinese Platform

-

A multi-role teaching workspace for international Chinese education

-

A Vue 3 frontend connecting student learning, teacher workflows, course interaction, live classes, and a digital-human classroom experience.

+

A self-contained, full-stack teaching workspace for international Chinese education

+

Student learning, teacher delivery, and platform review in one runnable, testable, deployable workflow.

- Features + Overview · - Getting Started + Completeness · - 中文 + Quick Start + · + Documentation · - Report an Issue + 中文

CI - Vue 3 - Vite 6 - Pinia 2 - Tailwind CSS 3 - pnpm 8.15.9 + API tests: 53 passing + Vue 3.5 + Fastify 5 + SQLite + Node.js 24 + pnpm 11.9.0 + Docker Compose

--- -## 📖 Overview + -International Chinese Platform is a multi-role frontend organized around real international Chinese teaching workflows. It is more than a single-page showcase: one Vue 3 application contains separate student, teacher, and administrator workspaces for course discovery, teacher booking, learning requests, homework, dialogue generation, live classes, a digital-human classroom, course uploads, and platform-management views. +## Overview -Role-aware routing, persisted client state, and centralized runtime configuration keep browser workflows decoupled from external REST, dialogue-generation, and WebSocket services. The repository works well as an education-product prototype, a frontend/backend integration base, and an evolving portfolio project. +International Chinese Platform is a modular monolith built around real teaching collaboration. Its Vue 3 client, Fastify API, SQLite persistence, file service, and real-time classroom protocol all live in this repository. The backend is in [`server/`](./server/), and [`server/app.js`](./server/app.js) registers every domain route. No unknown or obsolete backend from an earlier repository is required. -> [!IMPORTANT] -> This repository contains the frontend only. Production-ready authentication, registration, verification codes, courses, teachers, homework, dialogue generation, and real-time classroom behavior still require external backend, signaling, and media infrastructure. This README distinguishes implemented frontend behavior from service-dependent and prototype functionality. +The product contains separate student, teacher, and administrator workspaces. Course moderation, teacher appointments, and homework grading move between those roles through database-backed workflows. Notifications and audit records are persisted with business transitions instead of being simulated in the browser. -## ✨ Project Highlights +> [!NOTE] +> In this repository, “complete” means that the core teaching workflows can be installed, migrated, started, demonstrated, tested, backed up, and restored within a single-instance deployment boundary. It does not claim to be a commercial LMS with payments, formal enrollment, lesson recording, or unlimited horizontal scaling. No hosted demo or screenshots are invented here. -- **Purposeful multi-role structure** — Students, teachers, and administrators have separate layouts, entry points, navigation, and role guards instead of sharing one overloaded dashboard. -- **A broad teaching journey** — Course discovery, teacher booking, learning requests, homework, dialogues, live interaction, and personal settings form a recognizable online teaching flow. -- **Real-time and digital teaching exploration** — Camera, microphone, screen sharing, WebRTC foundations, class-matching animation, and a digital-human classroom prototype are included. -- **Explicit service boundaries** — REST, dialogue-generation, and WebSocket addresses are injected through environment variables with same-origin fallbacks. -- **A maintainable engineering baseline** — pnpm lockfile installs, ESLint, Prettier, production builds, and GitHub Actions create a repeatable quality gate. -- **Auditable repository consolidation** — Useful history from three early repositories is connected through merge commits in this canonical repository. +### Portfolio Highlights -## 🎯 Key Features +- **More than a frontend shell** — Authentication, authorization, domain APIs, migrations, demo data, uploads, email integration, WebSockets, tests, and a production container are maintained together. +- **Explicit workflow state machines** — A rejected course can be revised and resubmitted; an accepted appointment creates a classroom after conflict checks; homework moves from draft through submission to grading under server-side rules. +- **Consistent cross-role data** — Students, teachers, and administrators operate on the same SQLite records. Notifications, review history, and important audit events are written with state changes. +- **Explainable security defaults** — Server-side opaque sessions, HttpOnly cookies, scrypt password hashing, origin checks, role and ownership checks, rate limiting, and file-signature validation are built in. +- **Bounded external dependencies** — SMTP, AI, and TURN are adapters. If external AI is absent or fails, a deterministic local dialogue provider keeps the teaching flow usable. +- **Operationally inspectable** — Ordered migrations, one-time administrator bootstrap, a multi-stage Docker image, health probes, persistent volumes, backup instructions, and recovery procedures are included. -### 👨‍🎓 Student Workspace + -- **Course and teacher discovery** — Browse course and teacher views and enter their detail and booking flows. -- **Teacher booking** — Create an appointment, persist it in the browser, and show the latest booking on the student home page. -- **Learning requests** — Capture goals, schedule, and preferences as a request draft that can later be matched with a teacher. -- **Dialogue generation** — Submit keywords to the dedicated dialogue service and display generated practice turns. -- **Homework workspace** — Load homework, save and restore browser drafts, validate answers, and call the submission API. -- **Live and digital-human classes** — Explore live-class media controls, animated class matching, and a digital-human teaching view. -- **Personal center** — Navigate personal information, membership, password, and notification pages. +## Completeness Definition -### 👩‍🏫 Teacher Workspace +| Category | Status | Repository commitment | +| --------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Core teaching domain | ✅ Complete | Three-role authentication and authorization, teacher verification, course review, appointments and classrooms, homework grading, notifications, profiles, files, and dialogues have frontend, backend, and persistence | +| Engineering and operations | ✅ Complete | Reproducible installation, automatic migrations, demo data, API tests, production build, CI, Docker Compose, health checks, admin bootstrap, and backup/recovery guidance | +| SMTP email | ◇ Conditional | Local development can return a development verification code; enabling production registration requires `SMTP_URL`, `MAIL_FROM`, and a stable verification secret | +| AI dialogue | ◇ Optional adapter | A compatible external JSON API may be configured; timeout, failure, or no configuration falls back to deterministic local teaching dialogue | +| TURN relay | ◇ Conditional | WebRTC can work when peers are directly reachable; strict NAT, enterprise networks, and dependable production classrooms require TURN | +| Commercial LMS capabilities | — Out of scope | Payments, formal enrollment and capacity allocation, issuer-backed certificate verification/OCR, classroom recording, content-rights workflows, and multi-node WebSockets are not implemented | -- **Teaching dashboard** — Organize teacher home, teaching-coordination, and course-management entry points. -- **Online courses and details** — Browse teacher course views and course-detail flows. -- **Course-upload prototype** — Select course files, validate type and size, preview a cover, and save a browser-local course draft. -- **Teacher account view** — Provide the structure for teacher information and account pages. +Price and capacity are informational course fields; they do not represent payment or seat allocation. Until a formal enrollment model is added, assignments for published courses are visible and submittable by every authenticated student. The SQLite topology is intentionally one process and one application replica. Horizontal scaling first requires a new persistence layer and a distributed room-coordination design. -### 🧭 Administrator Workspace +## Role Workspaces -- **Course coordination** — Provide a unified course-coordination entry point. -- **Audit center** — Model a content-review workspace. -- **Data center** — Present an early platform analytics dashboard. -- **Account and notifications** — Include administrator password and notification views. +### Student -### ⚙️ Platform Capabilities +- Register, sign in, restore a session, update a profile, and change a password. +- Discover published courses and professional profiles for platform-verified teachers, create or cancel an appointment, and receive state notifications. +- Enter a classroom during its join window, use chat, hand raising, and WebRTC audio/video signaling, and confirm classroom completion. +- View published assignments, save server-side drafts, submit answers, and read teacher scores and feedback. +- Create persistent Chinese dialogue exercises; local teaching feedback remains available without external AI. +- Follow notification deep links back to the relevant appointment, classroom, or assignment context. -- **Role session and routing guards** — Protect student, teacher, and administrator routes and preserve the original destination across login redirects. -- **Persisted client state** — Use Pinia and its persistence plugin for frontend sessions, student data, and bookings. -- **Centralized networking** — Share Axios timeout, error handling, URL normalization, and environment-aware service addresses. -- **Real-time foundations** — Wrap WebSocket management plus camera, microphone, screen sharing, and WebRTC peer connections. -- **Internationalization foundation** — Integrate Vue i18n and a language selector for future full interface localization. +### Teacher -## 👥 Roles and Entry Points +- Maintain school, title, experience, specialties, languages, teaching style, and other professional profile fields. +- Enter the administrator verification queue after registration; editing personal or professional data resets an existing verification for review. +- Create and edit courses, submit them for review, and revise rejected courses before resubmission. +- Accept or reject appointments while the server checks schedule conflicts for both teacher and student. +- Join and complete classrooms, read message history, and participate in live interaction. +- Create, edit, publish, and close assignments; inspect submissions and record grades and feedback. -| Role | Default entry | Primary tasks | -| ------------- | ------------------------------ | ------------------------------------------------------------------------------------------------- | -| Student | `/student/home` | Courses, booking, learning requests, homework, dialogues, live classes, and digital-human classes | -| Teacher | `/teacher/home` | Teaching coordination, online courses, course uploads, details, and account information | -| Administrator | `/administrator/courseDocking` | Course coordination, audits, analytics, account, and notifications | +### Administrator -Every protected role route passes through frontend session and role checks. Anonymous visitors return to `/login`; a role mismatch redirects to the current role's default home page. +- Review pending courses, approve publication, or reject with a required explanation. +- Review submitted teacher institution, title, and certificate data; approve or revoke platform verification with an audit note. +- Inspect aggregate user, course, appointment, assignment, submission, and review metrics. +- Review recent activity generated by important domain audit events. +- Maintain the administrator session, password, and notifications. The first production administrator is created through a one-time bootstrap command. -## 🏗️ Architecture +## Four End-to-End State Loops ```mermaid -flowchart LR - Login["Login and role selection"] --> Guard["Client session and role guards"] - Guard --> Student["Student workspace"] - Guard --> Teacher["Teacher workspace"] - Guard --> Admin["Administrator workspace"] - - Student --> State["Persisted Pinia state"] - Teacher --> State - Admin --> State - - Student --> HTTP["Axios / REST"] - Teacher --> HTTP - Admin --> HTTP - Student --> Realtime["WebSocket / WebRTC"] - - HTTP -. "VITE_API_BASE_URL / VITE_FORUM_API_URL" .-> Services["External backend services"] - Realtime -. "VITE_WEBSOCKET_URL" .-> Services +flowchart TB + subgraph Verification["Teacher verification loop"] + T1["pending · registration or profile edit"] -->|administrator approves after manual review| T2["verified · publicly discoverable and bookable"] + T2 -->|administrator revokes or teacher edits profile| T1 + end + + subgraph Course["Course review loop"] + C1["draft · teacher creates"] -->|submit| C2["pending"] + C2 -->|administrator approves| C3["published · visible to students"] + C2 -->|administrator rejects with reason| C4["rejected"] + C4 -->|teacher revises and resubmits| C2 + end + + subgraph Appointment["Appointment and classroom loop"] + A1["pending · student requests"] -->|teacher accepts + both schedules checked| A2["accepted + scheduled classroom"] + A1 -->|teacher rejects| A3["rejected"] + A1 -->|participant cancels| A4["cancelled"] + A2 -->|join window + one-time ticket| A5["WebSocket / WebRTC classroom"] + A2 -->|participant cancels| A4 + A5 -->|participant completes lesson| A6["completed + closed classroom"] + end + + subgraph Homework["Homework submission and grading loop"] + H1["draft · teacher authors"] -->|publish after deadline validation| H2["published"] + H2 -->|student saves| H3["draft submission"] + H3 -->|submit before deadline| H4["submitted"] + H4 -->|teacher grades| H5["graded · score and feedback"] + H2 -->|teacher closes| H6["closed · no new writes"] + end ``` -`src/config/runtime.js` normalizes service addresses in one place. Without explicit configuration, REST uses the current origin, dialogue generation falls back to `/process_words`, and WebSocket selects `ws://` or `wss://` from the current page. +The server validates these transitions and combines transactions, uniqueness constraints, notification deduplication, and audit records to prevent browser bypasses and partial writes. -## 📊 Capability Maturity +## Architecture -| Capability | Status | Current boundary | -| ---------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------- | -| Multi-role layouts, routes, and client session | ✅ Implemented | Production identity, authorization, and token lifecycle remain backend responsibilities | -| Booking, homework, and course-upload flows | 🟡 Frontend flow | Booking persists locally; homework depends on APIs; uploads currently save a browser draft | -| Course, teacher, and administration data | 🟡 Mixed | Some views request APIs, while several lists and dashboards still use static or demonstration data | -| Live-class media controls | 🧪 Prototype | Camera, microphone, and screen sharing work; room signaling, TURN, and chat protocols remain incomplete | -| Digital-human classroom | 🧪 Prototype | The current interaction is simulated and is not connected to real AI, ASR, or TTS services | -| Internationalization | 🧪 Foundation | Vue i18n and language selection exist, but most product copy is still Chinese-first | -| Engineering quality gate | ✅ Implemented | CI covers install, ESLint, Prettier, and production build; automated tests remain planned | +```mermaid +flowchart LR + Browser["Browser · student / teacher / administrator"] -->|"same-origin pages and /api/v1"| App["Fastify 5 · server/app.js"] + Browser <-->|"/ws/classroom · ticketed WebSocket"| App -## 🛠️ Technology Stack + App --> Static["production assets · dist/"] + App --> Routes["auth, courses, appointments, homework, notifications, files, dialogues"] + App --> Realtime["class chat, presence, hand raising, RTC signaling"] -| Layer | Technology | -| ------------------------ | ------------------------------------------------- | -| Frontend | Vue 3.5, Single-File Components | -| Build | Vite 6 | -| Routing | Vue Router 4, lazy nested routes, role guards | -| State | Pinia, pinia-plugin-persistedstate | -| UI and styling | Element Plus, Tailwind CSS 3, PostCSS, Sass | -| Networking and real time | Axios, WebSocket, WebRTC | -| Internationalization | Vue i18n | -| Engineering | pnpm 8.15.9, ESLint 9, Prettier 3, GitHub Actions | + Routes --> DB[("SQLite · WAL + ordered migrations")] + Realtime --> DB + Routes --> Files["persistent local upload directory"] + + Routes -. "optional" .-> SMTP["SMTP email"] + Routes -. "optional + local fallback" .-> AI["external AI"] + Realtime -. "strict NAT" .-> TURN["TURN relay"] +``` -## 🚀 Getting Started +During development, Vite serves the client at `http://localhost:5173` and proxies `/api` and `/ws` to `http://127.0.0.1:7777`. In production, one Fastify process on port `7777` serves `dist/`, `/api/v1` (including access-controlled file content), and WebSockets from the same origin. A reverse proxy should terminate public TLS. + +## Technology Stack + +| Layer | Technology | +| --------------------------- | ---------------------------------------------------------------------------- | +| Web client | Vue 3.5, Vite 6, Vue Router 4, Pinia 2 | +| UI and styling | Element Plus 2, Tailwind CSS 3, Sass | +| API | Fastify 5, Zod 4, Node.js ESM | +| Data | SQLite, better-sqlite3, WAL, ordered migration ledger | +| Authentication and security | scrypt, opaque server-side sessions, HttpOnly cookies, Helmet, rate limiting | +| Live classroom | WebSocket, WebRTC signaling, one-time short-lived classroom tickets | +| External adapters | Nodemailer / SMTP, HTTP AI provider, TURN | +| Quality and delivery | Node test runner, ESLint 9, Prettier 3, GitHub Actions, Docker Compose | + + + +## Quick Start ### Prerequisites -- Node.js `>= 18` -- pnpm `8.15.9` (pinned in `package.json`) +- Node.js 24 is recommended; the minimum in `package.json` is Node.js 22. +- pnpm `11.9.0`. +- Python 3.12 and Playwright Chromium are additionally required for browser E2E. -### 1. Clone the repository +### 1. Clone and install ```bash git clone https://github.com/computersciencefreshmen/International_Chinese_Platform.git cd International_Chinese_Platform -``` - -### 2. Install dependencies - -```bash corepack enable -corepack prepare pnpm@8.15.9 --activate +corepack prepare pnpm@11.9.0 --activate pnpm install --frozen-lockfile ``` -### 3. Configure environment variables +### 2. Create a local configuration + +macOS / Linux: ```bash cp .env.example .env.local @@ -177,139 +204,262 @@ Windows PowerShell: Copy-Item .env.example .env.local ``` -Update `.env.local` for your backend and signaling services. +The defaults are enough for the complete local workflow. SMTP, external AI, and TURN are not prerequisites for the local demonstration. + +### 3. Initialize the demo database + +```bash +pnpm db:reset +``` + +> [!CAUTION] +> `db:reset` deletes the SQLite file at `DATABASE_PATH`, including its WAL/SHM companions, before rebuilding demo data. Use it only for a local development database. Production releases must migrate after taking a backup. -### 4. Start the development server +### 4. Start the web client and API ```bash pnpm dev ``` -Open the local URL printed in the terminal, normally `http://localhost:5173`. +- Web: +- API: +- Health: + +### Demo Accounts -## 🔧 Environment Variables +| Role | Email | Password | Default entry | +| ------------- | --------------------- | ---------- | ------------------------------ | +| Student | `student@example.com` | `Demo123!` | `/student/home` | +| Teacher | `teacher@example.com` | `Demo123!` | `/teacher/home` | +| Administrator | `admin@example.com` | `Demo123!` | `/administrator/courseDocking` | -| Variable | Purpose | Code fallback when unset | -| -------------------- | -------------------------------------- | -------------------------------------------------------------- | -| `VITE_API_BASE_URL` | REST API origin | `/` (current site origin) | -| `VITE_FORUM_API_URL` | Full keyword-to-dialogue endpoint | `/process_words` | -| `VITE_WEBSOCKET_URL` | Chat and classroom-signaling WebSocket | Current site's `/websocket`, automatically using `ws` or `wss` | +These accounts are created by the local seed and must not be used in production. The production image fixes `SEED_ON_START=false`. -`.env.example` contains local development examples. Every `VITE_` variable becomes visible in the browser bundle; never store passwords, private keys, or long-lived server secrets in these values. +## Environment Variables -## 📜 Commands +See [`.env.example`](./.env.example) for the full local template and [`docs/operations.md`](./docs/operations.md) for production values and secret-handling requirements. -| Command | Description | -| ------------------- | ------------------------------------------------- | -| `pnpm dev` | Start the Vite development server | -| `pnpm build` | Create a production build in `dist/` | -| `pnpm preview` | Preview the production build locally | -| `pnpm lint` | Apply auto-fixable ESLint changes | -| `pnpm lint:check` | Run ESLint without modifying files | -| `pnpm format` | Format `src/` | -| `pnpm format:check` | Check Prettier formatting in `src/` | -| `pnpm check` | Run lint, formatting, and production-build checks | +| Variable | Purpose | Default or boundary | +| ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `VITE_API_BASE_URL` | Browser API prefix | `/api/v1`; same-origin is recommended | +| `HOST` / `PORT` | API bind address and port | `127.0.0.1` / `7777` | +| `DATABASE_PATH` | SQLite file | `.data/platform.db` | +| `UPLOAD_DIR` | Uploaded-file directory | `.data/uploads` | +| `UPLOAD_OWNER_QUOTA_BYTES` / `UPLOAD_TOTAL_QUOTA_BYTES` | Per-account and platform upload quotas | 250 MiB / 5 GiB by default, reserved atomically inside one instance | +| `UPLOAD_MAX_CONCURRENT` | Global concurrent upload slots | `4` by default, maximum `32` | +| `APP_ORIGIN` | Exact origin allowed for cookie-authenticated writes | `http://localhost:5173` in development; the public HTTPS origin in production | +| `SESSION_TTL_HOURS` | Session lifetime | `12` | +| `VERIFICATION_CODE_SECRET` | Registration-code HMAC secret | A stable, high-entropy value is required in production | +| `SMTP_URL` / `MAIL_FROM` | Registration-code delivery | Required when production registration is enabled | +| `AI_API_URL` / `AI_API_KEY` | Optional external dialogue generator | Deterministic local implementation on absence or failure | +| `TURN_URL` / `TURN_USERNAME` / `TURN_CREDENTIAL` | Optional WebRTC relay | Required for strict NAT and dependable production classrooms | +| `SEED_ON_START` | Insert demo data at startup | Enabled by default in development; fixed off in production Compose | +| `SECURE_COOKIES` / `ALLOW_BEARER_AUTH` / `TRUST_PROXY` | Cookies, bearer compatibility, and proxy trust boundary | Production Compose enables secure cookies and disables bearer auth; proxy trust must match the topology | +| `ADMIN_EMAIL` / `ADMIN_PASSWORD` / `ADMIN_DISPLAY_NAME` | One-time first-admin bootstrap | Inject only while running the command; do not store in the production environment file | -Run before committing: +Every `VITE_` variable is embedded in the browser bundle and must never contain a secret. Production registration requires both SMTP and a verification secret. In development only, the API returns a local verification code when SMTP is absent. + +## Migrations and Administrator Bootstrap + +When the database opens, it reads the `schema_migrations` ledger and applies migrations from [`server/db/database.js`](./server/db/database.js) in order and inside transactions. They can also be run explicitly: ```bash -pnpm check +pnpm db:migrate ``` -## 📁 Project Structure +Production does not seed a demo administrator. After the first deployment, inject `ADMIN_EMAIL`, `ADMIN_PASSWORD`, and `ADMIN_DISPLAY_NAME` temporarily, then run: -```text -International_Chinese_Platform/ -├── .github/ -│ └── workflows/ci.yml # GitHub Actions quality gate -├── public/ # Public static assets -├── src/ -│ ├── api/ # Student, user, and shared API wrappers -│ ├── assets/ # Styles, icons, and course media -│ ├── components/ # Base, domain, and class-matching components -│ ├── config/ # REST / Forum / WebSocket runtime configuration -│ ├── i18n/ # i18n instance and Chinese/English messages -│ ├── router/ # Multi-role nested routes and guards -│ ├── stores/ # Pinia session, student, and administrator state -│ ├── utils/ # Axios and WebSocket utilities -│ └── views/ -│ ├── student/ # Student-facing pages -│ ├── teacher/ # Teacher-facing pages -│ ├── administrator/ # Administrator-facing pages -│ ├── liveClass/ # Live-class UI and WebRTC composable -│ └── login/ # Login and registration views -├── .env.example # Local service-address examples -├── package.json # Scripts, dependencies, and runtime requirements -├── pnpm-lock.yaml # Reproducible dependency lockfile -└── vite.config.js # Vite configuration +```bash +pnpm admin:bootstrap +``` + +The password must be at least 12 characters and contain uppercase, lowercase, numeric, and special characters. The command creates only the first administrator and refuses to overwrite an existing one. See the [operations manual](./docs/operations.md) for secure Docker injection, backups, migrations, and recovery. + +## Tests and Quality Gates + +```bash +pnpm test:api +pnpm check ``` -## ✅ Quality Assurance +| Layer | Current coverage | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| API integration | **53 / 53 passing**; authentication, teacher review, courses, appointments and classrooms, assignments, migrations, email, streamed uploads and quotas, notifications, profiles, dialogues, and real-time isolation | +| Browser E2E | **4 cross-role workflows passing**; real UI login for every role, then teacher verification revoke/restore, course review, appointment/classroom completion, and homework submission/grading | +| Static quality | `pnpm check` runs ESLint, Prettier, API tests, and the production build in sequence | +| Continuous integration | Separate Validate and Cross-role browser E2E jobs with Node 24, pnpm 11.9.0, Python 3.12, a frozen lockfile, and an isolated database | -| Layer | Gate | -| ---------------------- | ------------------------------------------------------------------------------------------------- | -| Static quality | Read-only ESLint check | -| Formatting | Prettier check | -| Buildability | Vite production build | -| Continuous integration | Ubuntu + Node.js 20 + pnpm 8.15.9 + frozen lockfile | -| Browser validation | Manual smoke checks for login, role redirects, the student dashboard, and the digital-human route | +The browser suite uses a pinned Python Playwright version. Install its dependencies and build the production client first: -GitHub Actions runs the same install, check, and build sequence for pull requests and pushes to `main`. Automated unit, component, and end-to-end tests are not configured yet, so critical product changes still require browser validation. +```bash +python -m pip install --requirement e2e/requirements.txt +python -m playwright install chromium +pnpm build +``` -## 🔗 Repository Consolidation +Start an isolated production-mode service in the first terminal. On macOS / Linux: -This is the project's only canonical development repository: +```bash +mkdir -p .data +rm -f .data/e2e.db .data/e2e.db-shm .data/e2e.db-wal +NODE_ENV=production \ +SEED_ON_START=true \ +SECURE_COOKIES=false \ +APP_ORIGIN=http://localhost:7777 \ +VERIFICATION_CODE_SECRET=e2e-only-verification-secret-32-characters \ +DATABASE_PATH=.data/e2e.db \ +pnpm start +``` -> [computersciencefreshmen/International_Chinese_Platform](https://github.com/computersciencefreshmen/International_Chinese_Platform) +On Windows PowerShell: -Early code was split across: +```powershell +New-Item -ItemType Directory -Force .data | Out-Null +Remove-Item -Force -ErrorAction SilentlyContinue .data/e2e.db, .data/e2e.db-shm, .data/e2e.db-wal +$env:NODE_ENV = 'production' +$env:SEED_ON_START = 'true' +$env:SECURE_COOKIES = 'false' +$env:APP_ORIGIN = 'http://localhost:7777' +$env:VERIFICATION_CODE_SECRET = 'e2e-only-verification-secret-32-characters' +$env:DATABASE_PATH = '.data/e2e.db' +pnpm start +``` + +Run the suite from a second terminal: -- [computersciencefreshmen/vue3-project-initialization](https://github.com/computersciencefreshmen/vue3-project-initialization) -- [computersciencefreshmen/project](https://github.com/computersciencefreshmen/project) +```bash +python e2e/test_workflows.py +``` -Useful commits from the older repositories are now reachable from the current `main` through standard Git merge commits, including the initialization history that originally had no common ancestor. New code, issues, documentation, and releases should use this repository; the older repositories are historical references only. +The default target is `http://localhost:7777`. Set `E2E_BASE_URL` to override it or `E2E_BROWSER_EXECUTABLE` to use a local Chrome binary. On failure, screenshots, traces, and browser logs are written to `test-results/e2e/`. CI builds the app, installs Chromium, starts an isolated seeded database, and uploads failure artifacts automatically. -## 🗺️ Roadmap +API tests use temporary databases and do not depend on the demo database. The project does not claim a publicly hosted demo; reproducible verification is provided through local startup, automated tests, and Docker deployment. -- [x] Consolidate three repository histories and establish one canonical home -- [x] Build student, teacher, and administrator route structures -- [x] Centralize REST, dialogue-service, and WebSocket runtime configuration -- [x] Add ESLint, Prettier, production-build, and GitHub Actions gates -- [ ] Align the complete backend contract and replace static course, teacher, and administration data -- [ ] Complete WebRTC rooms, signaling, TURN, and classroom chat protocols -- [ ] Connect real digital-human, AI, ASR, and TTS services -- [ ] Localize all product copy in Chinese and English -- [ ] Add unit, component, and end-to-end automated tests -- [ ] Add production deployment, monitoring, security guidance, and an open-source license +## Same-Origin Docker Deployment -## 🚢 Deployment +The production image uses a Node 24 multi-stage build and runs as a non-root user. After preparing a restricted `.env.production` file: ```bash -pnpm build +docker compose --env-file .env.production build --pull +docker compose --env-file .env.production up -d +docker compose --env-file .env.production ps ``` -The production bundle is written to `dist/`. A deployment must: +Compose binds the app to host `127.0.0.1:7777` by default and stores the database and uploads in separate persistent volumes. The reverse proxy must expose the page, `/api/v1`, uploaded content, and `/ws` under one public origin. Do not publish the API on a separate domain. + +Read the [production operations manual](./docs/operations.md) before deployment. It covers TLS proxying, administrator bootstrap, migration, cold backups, recovery drills, monitoring, and incident response so that “the image built” is not treated as “production ready.” + +## Project Structure -1. Inject the correct `VITE_*` variables during the build. -2. Configure SPA fallback for Vue Router history mode so unknown routes return `index.html`. -3. Use HTTPS and WSS, with correct backend CORS policies. -4. Provide authenticated signaling, TURN infrastructure, and media-security policies for production live classes. -5. Verify complete login, course, homework, dialogue, and real-time classroom flows before release. +```text +International_Chinese_Platform/ +├── src/ # Vue 3 client and three role workspaces +│ ├── api/ # Same-origin API client +│ ├── components/ # Account, service, and base components +│ ├── router/ # Session restoration and role guards +│ ├── stores/ # Pinia state +│ └── views/ # Student, teacher, administrator, classroom views +├── server/ # Fastify backend, not an external dependency +│ ├── app.js # Composition root and route registration +│ ├── routes/ # Authentication, domain APIs, and WebSocket +│ ├── db/ # Schema, migrations, seed, reset, admin bootstrap +│ ├── services/ # SMTP and AI adapters +│ └── test/ # 53 API integration tests +├── e2e/ # Cross-role Python Playwright tests +├── docs/ +│ ├── adr/ # Architecture decision records +│ ├── plans/ # Completion design and implementation plans +│ └── operations.md # Production, backup, and recovery manual +├── .github/workflows/ci.yml # Quality gates and browser E2E +├── Dockerfile # Node 24 multi-stage production image +├── docker-compose.yml # Single-instance deployment with two persistent volumes +├── SECURITY.md # Vulnerability reporting and security expectations +└── .env.example # Local configuration template +``` -## 🤝 Contributing +## API Groups + +All REST endpoints use the `/api/v1` prefix and return a consistent `{ code, msg, data }` envelope. [`server/app.js`](./server/app.js) is the source of truth for registered modules. + +| Group | Main paths | Capability | +| ------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| System | `/health`, `/ready` | Liveness and database readiness | +| Auth | `/auth/verification-code`, `/auth/register`, `/auth/login`, `/auth/logout`, `/auth/session` | Verification, account, and session lifecycle | +| Profile & Teachers | `/me`, `/me/password`, `/teachers` | Profiles, professional teacher information, and discovery | +| Courses & Admin | `/courses`, `/admin/course-reviews`, `/admin/teacher-verifications`, `/admin/metrics` | Course CRUD, course and teacher review, and operational metrics | +| Appointments & Classrooms | `/appointments`, `/classrooms/:id/*` | Appointment responses, conflict checks, join information, and classroom completion | +| Assignments | `/courses/:id/assignments`, `/assignments`, `/submissions`, `/me/submissions` | Assignment lifecycle, student submissions, and teacher grading | +| Files | `/files`, `/files/:id/content` | Categorized uploads, content validation, metadata, and deletion | +| Dialogues | `/dialogues`, `/dialogues/:id/messages` | Persistent dialogue generation and continuation | +| Notifications | `/notifications` | Pagination, unread state, and mark-all-read | +| Realtime | `/classrooms/:id/ticket`, `/classrooms/:id/messages`, `/ws/classroom` | One-time tickets, history, presence, chat, and RTC signaling | + +## Security Design + +- Passwords use parameterized scrypt hashes; login performs a password check even when the account is absent to reduce enumeration signals. +- Browsers receive only an HttpOnly, SameSite session cookie. The database stores a digest of the opaque token; logout, password changes, and expiration revoke sessions. +- Cookie-authenticated writes validate `Origin`; production Compose enables secure cookies and disables bearer authentication by default. +- Domain writes enforce roles and resource relationships, and the server rechecks each state transition. Non-public file content, metadata, and deletion are limited to the owner or an administrator. +- Unverified teachers are not publicly discoverable or bookable. Administrator approval/revocation notifies the teacher and is audited; a later profile edit resets verification for review. +- Zod validates inputs, Helmet applies security headers, and Fastify rate limits requests and sensitive endpoints. +- Uploads stream through private temporary files with incremental hashing, signature checks, same-volume atomic rename, role and size rules, per-account/platform quotas, and global concurrency limits. This is not malware scanning. +- Verification codes are stored as digests, have bounded attempts, and are consumed once. Failed mail delivery does not create a usable registration path. +- Important moderation, appointment, assignment, and administrator-bootstrap actions are written to the audit log. + +Classroom ticket issuance, the WebSocket handshake, history reads, every realtime event, and the periodic connection sweep all revalidate the session, account, classroom state, and participant relationship; stale connections are closed. Non-public course videos and materials are currently readable only by their owner or an administrator. Because formal enrollment is outside this project's scope, student entitlement to private course material is not modeled; deployments that distribute such material should add an enrollment or resource-grant model first. + +These controls are not a third-party security certification. Operators remain responsible for TLS, secrets, dependency updates, logging, retention, and backup access. Report vulnerabilities privately through [`SECURITY.md`](./SECURITY.md); do not disclose details in a public issue. + +## Production Boundaries + +| Boundary | Current design | Expansion path | +| ------------------------ | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Database | SQLite WAL with one application replica | Move to a service database and reassess transactions and migrations before multi-instance operation | +| Live classroom | In-process WebSocket rooms with peer-to-peer WebRTC media | Multiple nodes require shared presence/pub-sub, sticky routing, and separate media infrastructure | +| Network traversal | TURN is optional configuration | Strict NAT or enterprise-network production classrooms require a monitored TURN service | +| Files | Local volume; default 250 MiB/account, 5 GiB/platform, and four concurrent upload slots | Larger deployments may move to object storage with scanning, lifecycle rules, and CDN delivery | +| Email | SMTP adapter | Production registration requires a deliverable mail service and failure monitoring | +| AI | One HTTP adapter with deterministic local fallback | Provider policy, budget controls, safety, and observability can be added | +| Teaching commerce | Administrator-driven platform teacher verification; price and capacity remain profile data | Payments, formal enrollment, and issuer-backed certificate verification/OCR need separate domains | +| Classroom media | Live audio/video and signaling | Recording, transcoding, playback, and content moderation are not implemented | +| Continuous authorization | Tickets, handshakes, every event, and periodic sweeps revalidate access; private materials are owner/admin only | Add shared revocation signaling for multiple instances and enrollment-based student material access | + + + +## Documentation + +- [`README.md`](./README.md) — Primary Chinese documentation. +- [`README.en.md`](./README.en.md) — Equivalent English documentation. +- [`docs/operations.md`](./docs/operations.md) — Docker production deployment, reverse proxy, migrations, backup, recovery, and monitoring. +- [`SECURITY.md`](./SECURITY.md) — Vulnerability reporting and operator security expectations. +- [`docs/adr/0001-self-contained-modular-monolith.md`](./docs/adr/0001-self-contained-modular-monolith.md) — Self-contained modular monolith decision. +- [`docs/adr/0002-server-side-opaque-sessions.md`](./docs/adr/0002-server-side-opaque-sessions.md) — Opaque server-side session decision. +- [`docs/plans/2026-07-19-full-stack-completion-design.md`](./docs/plans/2026-07-19-full-stack-completion-design.md) — Completion design. +- [`docs/plans/2026-07-19-full-stack-completion.md`](./docs/plans/2026-07-19-full-stack-completion.md) — Implementation and acceptance plan. + +## Canonical Repository + +All future code, issues, documentation, and releases belong in this repository: -Issues and pull requests are welcome: +> [computersciencefreshmen/International_Chinese_Platform](https://github.com/computersciencefreshmen/International_Chinese_Platform) + +Early history was split between [`vue3-project-initialization`](https://github.com/computersciencefreshmen/vue3-project-initialization) and a repository named `project`. The latter is no longer reachable on GitHub, so this README does not retain a dead link. Relevant history has been merged here; any accessible older repository is only a historical reference and should not receive new feature work. + +## Contributing + +Create a focused branch from the latest `main`, and update tests and documentation with functional changes. Before submitting: + +```bash +pnpm check +``` -1. Create a clearly named branch from the latest `main`. -2. Keep the change focused and update related documentation. -3. Run `pnpm check` before committing. -4. Open a pull request and merge it into `main` with a standard merge commit after CI passes. +Merge a pull request back into `main` only after CI passes; do not leave completed features stranded on personal branches. -## 📄 License +## License -This repository does not currently declare an open-source license. Until one is added, do not assume permission to copy, distribute, or use the code commercially. +This repository currently declares **no open-source license**. Until the owner adds an explicit license, do not assume permission to copy, distribute, modify, or use the code commercially.
-

Built with ❤️ for international Chinese education.

- ⬆ Back to top +

Built for complete, inspectable international Chinese teaching workflows.

+ Back to top
diff --git a/README.md b/README.md index dc5dce6..c4cb5a8 100644 --- a/README.md +++ b/README.md @@ -1,171 +1,198 @@
- International Chinese Platform 标志 + International Chinese Platform 标志 -

🌏 International Chinese Platform

+

International Chinese Platform

-

面向国际中文教育的多角色教学协作平台

-

连接学生学习、教师授课、课程互动、直播课堂与数字人体验的 Vue 3 前端应用。

+

面向国际中文教育的自包含全栈教学协作平台

+

把学生学习、教师授课与平台审核放进同一套可运行、可测试、可部署的教学闭环。

- 功能概览 + 项目概览 · - 快速开始 + 完整性定义 · - English + 快速开始 + · + 文档 · - 问题反馈 + English

CI - Vue 3 - Vite 6 - Pinia 2 - Tailwind CSS 3 - pnpm 8.15.9 + API tests: 53 passing + Vue 3.5 + Fastify 5 + SQLite + Node.js 24 + pnpm 11.9.0 + Docker Compose

--- -## 📖 项目概览 + -International Chinese Platform 是一个围绕国际中文教学流程构建的多角色前端平台。它不是单页展示模板,而是将学生、教师和管理员工作区组织在同一个 Vue 3 应用中,覆盖课程发现、教师预约、学习需求发布、作业、话轮、直播课堂、数字人课堂、课程上传和平台管理等场景。 +## 项目概览 -项目采用角色路由、持久化状态和统一运行时配置,将浏览器端工作流与 REST、话轮生成服务、WebSocket 信令等外部能力解耦,适合作为国际中文教育产品原型、前后端联调基座和持续演进的作品项目。 +International Chinese Platform 是一个围绕真实教学协作流程构建的模块化单体。Vue 3 前端、Fastify API、SQLite 持久层、文件服务和实时课堂协议均位于本仓库;后端入口在 [`server/`](./server/),所有领域路由由 [`server/app.js`](./server/app.js) 统一注册。项目不依赖早期仓库中可能已经失效或版本不明的外部后端。 -> [!IMPORTANT] -> 本仓库只包含前端应用。登录、注册、验证码、课程、教师、作业、话轮生成及实时课堂的完整生产能力仍需外部后端、信令和媒体基础设施支持。README 会明确区分“已实现的前端能力”“依赖服务的流程”和“原型功能”。 +它覆盖学生、教师和管理员三个工作区,并让课程审核、教师预约与作业评分真正跨角色流转。关键状态保存在数据库中,通知和审计记录随业务事务写入,而不是只在页面中模拟成功。 -## ✨ 项目亮点 +> [!NOTE] +> 本仓库将“完整”定义为:在单实例部署边界内,可以独立安装、迁移、启动、演示、测试和备份核心教学流程。它不是一套宣称具备支付、正式选课、课堂录制和无限水平扩展能力的商业 LMS,也没有虚构在线 Demo 或截图。 -- **多角色产品结构** — 学生、教师和管理员拥有独立布局、入口、导航与角色路由守卫,而不是把所有页面堆叠在同一个工作台中。 -- **覆盖教学闭环** — 从课程浏览、教师预约和学习需求,到作业、话轮、直播互动与个人中心,呈现较完整的在线中文教学路径。 -- **实时与数字化教学探索** — 提供摄像头、麦克风、屏幕共享、WebRTC 信令基础、滴滴上课匹配动画和数字人课堂原型。 -- **清晰的服务边界** — REST、话轮服务和 WebSocket 地址全部通过环境变量注入,并在未配置时采用安全的同源回退。 -- **可持续工程基线** — 使用 pnpm 锁定依赖,配备 ESLint、Prettier、生产构建和 GitHub Actions 质量门禁。 -- **可审计的仓库整合** — 三个早期仓库的有效 Git 历史已通过合并提交统一到本仓库,后续开发不再分叉。 +### Portfolio Highlights -## 🎯 核心功能 +- **不是前端壳工程** — 认证、授权、领域 API、迁移、种子数据、上传、邮件适配、WebSocket、测试与生产容器都在同一仓库。 +- **围绕状态闭环建模** — 课程可驳回后修改再审;预约在冲突检查后生成课堂;作业从草稿、提交到评分均受服务端状态机约束。 +- **跨角色数据一致性** — 教师、学生与管理员看到同一份 SQLite 业务数据;通知、审核记录和关键操作日志与状态变更一起持久化。 +- **安全默认值可解释** — 使用服务端不透明会话、HttpOnly Cookie、scrypt 密码哈希、来源检查、角色与所有权校验、速率限制和文件魔数验证。 +- **外部依赖有降级边界** — SMTP、AI 和 TURN 都通过适配器接入;未配置外部 AI 时使用本地确定性对话,核心教学流程仍然可运行。 +- **能进入部署与运维阶段** — 提供有序迁移、一次性管理员引导、多阶段 Docker 镜像、健康探针、持久卷、备份和恢复说明。 -### 👨‍🎓 学生学习空间 + -- **课程与教师发现** — 浏览课程和教师信息,进入课程、教师详情及预约流程。 -- **教师预约** — 创建预约信息,将浏览器端预约持久化,并在学生首页回显最近预约。 -- **学习需求发布** — 填写学习目标、时间和偏好,形成可继续对接教师的需求草稿。 -- **话轮生成** — 提交关键词并调用独立话轮服务,生成对话练习内容。 -- **作业工作台** — 加载作业、保存浏览器草稿、恢复答案、校验并提交结果。 -- **直播与数字人课堂** — 提供直播课堂媒体控制、滴滴上课匹配交互和数字人教学页面。 -- **个人中心** — 管理个人信息、会员页面、密码入口和消息通知页面。 +## 完整性定义 -### 👩‍🏫 教师工作空间 +| 分类 | 状态 | 本仓库的承诺 | +| ------------- | ------------------ | ------------------------------------------------------------------------------------------------------------ | +| 核心教学域 | ✅ Complete | 三角色认证与权限、教师认证、课程审核、预约与课堂、作业提交评分、通知、档案、文件和对话均有前后端实现与持久化 | +| 工程与运维 | ✅ Complete | 可复现安装、自动迁移、演示数据、API 测试、生产构建、CI、Docker Compose、健康检查、管理员引导、备份与恢复文档 | +| SMTP 邮件 | ◇ Conditional | 本地开发可直接取得开发验证码;开放生产注册时必须配置 `SMTP_URL`、`MAIL_FROM` 和稳定的验证码密钥 | +| AI 对话 | ◇ Optional adapter | 可调用符合当前 JSON 契约的外部 AI;超时、失败或未配置时回退到本地确定性教学对话 | +| TURN 中继 | ◇ Conditional | 同网段或可直连环境可使用 WebRTC;严格 NAT、企业网络和可靠生产课堂必须配置 TURN | +| 商业 LMS 能力 | — Out of scope | 支付结算、正式选课与容量扣减、证书颁发方联网核验/OCR、课堂录制、版权工作流、多节点 WebSocket 不在当前范围 | -- **授课工作台** — 组织教师首页、授课对接和课程管理入口。 -- **网络课程与详情** — 浏览教师课程页面并进入课程详情流程。 -- **课程上传原型** — 支持课程资料选择、类型与大小校验、封面预览和浏览器内课程草稿。 -- **教师账户页面** — 提供教师信息与账户相关界面结构。 +价格和容量目前作为课程信息展示,不代表已经实现收费或正式席位占用。在正式选课模型加入前,已发布课程的作业对所有登录学生可见并可提交。SQLite 方案面向单进程、单副本;若要水平扩展,需先迁移持久层与实时房间协调机制。 -### 🧭 管理员工作空间 +## 三角色工作区 -- **课程对接** — 统一查看平台课程对接入口。 -- **审核中心** — 提供内容审核工作台页面结构。 -- **数据中心** — 展示平台数据看板原型。 -- **账户与消息** — 提供管理员密码和消息通知页面。 +### 学生 -### ⚙️ 平台基础能力 +- 注册、登录、恢复会话、维护资料和修改密码。 +- 浏览已发布课程与平台已认证教师的专业档案,创建或取消预约并接收状态通知。 +- 在可加入时间窗内进入课堂,使用聊天、举手和 WebRTC 音视频信令,并可确认完成课堂。 +- 查看已发布作业、保存服务端草稿、提交答案并查看教师评分与反馈。 +- 创建可持久化的中文对话练习;外部 AI 不可用时仍可使用本地教学反馈。 +- 通过通知深链回到对应预约、课堂或作业上下文。 -- **角色会话与路由守卫** — 根据登录状态和角色保护学生、教师、管理员路由,并支持登录后回到原目标页面。 -- **持久化状态** — 使用 Pinia 与持久化插件保存前端会话、学生资料和预约数据。 -- **统一网络层** — 使用 Axios 实例、超时配置、响应错误处理和环境化服务地址。 -- **实时通信基础** — 提供 WebSocket 管理以及摄像头、麦克风、屏幕共享和 WebRTC PeerConnection 封装。 -- **国际化基础设施** — 集成 Vue i18n 和语言切换组件,为后续完整多语言覆盖提供基础。 +### 教师 -## 👥 角色与入口 +- 维护学校、职称、教龄、专长、语言、教学风格等专业资料。 +- 注册后进入管理员认证队列;已认证教师修改个人或专业资料后会自动回到待审核状态。 +- 创建和编辑课程,提交审核,并根据驳回意见修改后再次提交。 +- 接受或拒绝学生预约;服务端同时检查教师与学生双方的时间冲突。 +- 进入、完成课堂,查看历史消息并参与实时互动。 +- 创建、编辑、发布和关闭作业,查看提交列表并完成评分反馈。 -| 角色 | 默认入口 | 主要任务 | -| ------ | ------------------------------ | ------------------------------------------------------ | -| 学生 | `/student/home` | 课程、教师预约、需求发布、作业、话轮、直播和数字人课堂 | -| 教师 | `/teacher/home` | 授课对接、网络课程、课程上传、课程详情和账户信息 | -| 管理员 | `/administrator/courseDocking` | 课程对接、审核、数据中心、账户和消息管理 | +### 管理员 -所有受保护的角色路由都经过前端会话与角色检查。未登录用户会被送回 `/login`,角色不匹配时会回到当前角色的默认首页。 +- 查看待审核课程,批准发布或填写理由驳回。 +- 核验教师提交的机构、职称和证书资料,批准或撤销平台内教师认证并填写审核依据。 +- 查看用户、课程、预约、作业、提交和审核等聚合指标。 +- 查看关键领域操作形成的近期审计活动。 +- 维护自己的会话、密码和通知;首个生产管理员通过一次性命令安全创建。 -## 🏗️ 应用架构 +## 四条核心状态闭环 ```mermaid -flowchart LR - Login["登录与角色选择"] --> Guard["前端会话与角色路由守卫"] - Guard --> Student["学生工作区"] - Guard --> Teacher["教师工作区"] - Guard --> Admin["管理员工作区"] - - Student --> State["Pinia 持久化状态"] - Teacher --> State - Admin --> State - - Student --> HTTP["Axios / REST"] - Teacher --> HTTP - Admin --> HTTP - Student --> Realtime["WebSocket / WebRTC"] - - HTTP -. "VITE_API_BASE_URL / VITE_FORUM_API_URL" .-> Services["外部后端服务"] - Realtime -. "VITE_WEBSOCKET_URL" .-> Services +flowchart TB + subgraph Verification["教师认证闭环"] + T1["pending · 教师注册或资料变更"] -->|管理员人工核验通过| T2["verified · 可公开展示与预约"] + T2 -->|管理员撤销或教师再次修改资料| T1 + end + + subgraph Course["课程审核闭环"] + C1["draft · 教师创建"] -->|提交审核| C2["pending"] + C2 -->|管理员通过| C3["published · 学生可见"] + C2 -->|管理员驳回并给出理由| C4["rejected"] + C4 -->|教师修改并重提| C2 + end + + subgraph Appointment["预约与课堂闭环"] + A1["pending · 学生发起"] -->|教师接受 + 双方冲突检查| A2["accepted + scheduled classroom"] + A1 -->|教师拒绝| A3["rejected"] + A1 -->|参与者取消| A4["cancelled"] + A2 -->|进入时间窗 + 一次性票据| A5["WebSocket / WebRTC classroom"] + A2 -->|参与者取消| A4 + A5 -->|参与者完成课堂| A6["completed + closed classroom"] + end + + subgraph Homework["作业提交评分闭环"] + H1["draft · 教师编写"] -->|截止时间校验后发布| H2["published"] + H2 -->|学生保存| H3["draft submission"] + H3 -->|截止前提交| H4["submitted"] + H4 -->|教师评分| H5["graded · 分数与反馈"] + H2 -->|教师关闭| H6["closed · 禁止新写入"] + end ``` -运行时地址由 `src/config/runtime.js` 统一归一化。未显式设置时,REST 使用同源地址,话轮服务回退到 `/process_words`,WebSocket 根据当前页面自动选择 `ws://` 或 `wss://`。 +这些转换在服务端校验,并结合事务、唯一约束、通知去重和审计记录防止页面绕过或部分写入。 + +## 应用架构 + +```mermaid +flowchart LR + Browser["浏览器 · 学生 / 教师 / 管理员"] -->|"同源页面与 /api/v1"| App["Fastify 5 · server/app.js"] + Browser <-->|"/ws/classroom · ticketed WebSocket"| App + + App --> Static["生产静态资源 · dist/"] + App --> Routes["认证、课程、预约、作业、通知、文件、对话"] + App --> Realtime["课堂聊天、在线状态、举手、RTC 信令"] + + Routes --> DB[("SQLite · WAL + ordered migrations")] + Realtime --> DB + Routes --> Files["本地持久上传目录"] + + Routes -. "optional" .-> SMTP["SMTP 邮件"] + Routes -. "optional + local fallback" .-> AI["外部 AI"] + Realtime -. "strict NAT" .-> TURN["TURN 中继"] +``` -## 📊 功能成熟度 +开发环境由 Vite 在 `http://localhost:5173` 提供页面,并把 `/api` 和 `/ws` 代理到 `http://127.0.0.1:7777`。生产环境由同一个 Fastify 进程在 `7777` 端口同源托管 `dist/`、`/api/v1`(包括受权限控制的文件内容)和 WebSocket;公网 TLS 应由反向代理终止。 -| 能力 | 状态 | 当前边界 | -| -------------------------- | ----------- | --------------------------------------------------------------- | -| 多角色布局、路由和前端会话 | ✅ 已实现 | 生产级身份认证、授权和令牌生命周期仍由后端负责 | -| 预约、作业和课程上传流程 | 🟡 前端流程 | 预约可本地持久化;作业依赖接口;课程上传目前保存浏览器草稿 | -| 课程、教师和管理数据 | 🟡 混合状态 | 部分页面请求接口,部分列表与管理看板仍使用静态或演示数据 | -| 直播课堂媒体控制 | 🧪 原型阶段 | 摄像头、麦克风和屏幕共享可交互;房间信令、TURN 和聊天协议待完善 | -| 数字人课堂 | 🧪 原型阶段 | 当前为交互与教学页面原型,尚未接入真实 AI、ASR 或 TTS 服务 | -| 国际化 | 🧪 基础设施 | 已有 Vue i18n 和语言切换,绝大多数业务文案仍以中文为主 | -| 工程质量门禁 | ✅ 已实现 | CI 覆盖安装、ESLint、Prettier 和生产构建;自动化测试待补充 | +## 技术栈 -## 🛠️ 技术栈 +| 层级 | 技术 | +| ---------- | ---------------------------------------------------------------------- | +| Web 客户端 | Vue 3.5、Vite 6、Vue Router 4、Pinia 2 | +| UI 与样式 | Element Plus 2、Tailwind CSS 3、Sass | +| API | Fastify 5、Zod 4、Node.js ESM | +| 数据 | SQLite、better-sqlite3、WAL、有序迁移台账 | +| 认证与安全 | scrypt、不透明服务端会话、HttpOnly Cookie、Helmet、Rate Limit | +| 实时课堂 | WebSocket、WebRTC 信令、一次性短期课堂票据 | +| 外部适配器 | Nodemailer / SMTP、HTTP AI provider、TURN | +| 质量与交付 | Node test runner、ESLint 9、Prettier 3、GitHub Actions、Docker Compose | -| 层级 | 技术 | -| -------------- | ------------------------------------------------- | -| 前端框架 | Vue 3.5、单文件组件(SFC) | -| 构建工具 | Vite 6 | -| 路由 | Vue Router 4、懒加载嵌套路由、角色守卫 | -| 状态管理 | Pinia、pinia-plugin-persistedstate | -| UI 与样式 | Element Plus、Tailwind CSS 3、PostCSS、Sass | -| 网络与实时能力 | Axios、WebSocket、WebRTC | -| 国际化 | Vue i18n | -| 工程工具 | pnpm 8.15.9、ESLint 9、Prettier 3、GitHub Actions | + -## 🚀 快速开始 +## 快速开始 ### 前置要求 -- Node.js `>= 18` -- pnpm `8.15.9`(已在 `package.json` 中固定) +- 推荐 Node.js 24;`package.json` 的最低版本为 Node.js 22。 +- pnpm `11.9.0`。 +- 如需浏览器 E2E,另需 Python 3.12 与 Playwright Chromium。 -### 1. 克隆仓库 +### 1. 克隆与安装 ```bash git clone https://github.com/computersciencefreshmen/International_Chinese_Platform.git cd International_Chinese_Platform -``` - -### 2. 安装依赖 - -```bash corepack enable -corepack prepare pnpm@8.15.9 --activate +corepack prepare pnpm@11.9.0 --activate pnpm install --frozen-lockfile ``` -### 3. 配置环境变量 +### 2. 创建本地配置 + +macOS / Linux: ```bash cp .env.example .env.local @@ -177,139 +204,262 @@ Windows PowerShell: Copy-Item .env.example .env.local ``` -根据你的后端和信令服务修改 `.env.local`。 +默认配置即可运行本地完整流程;外部 SMTP、AI 和 TURN 均不是本地演示的前置条件。 + +### 3. 初始化演示数据库 + +```bash +pnpm db:reset +``` + +> [!CAUTION] +> `db:reset` 会删除 `DATABASE_PATH` 指向的 SQLite 文件及 WAL/SHM 文件,再重建演示数据。只应对本地开发数据库使用;生产发布请运行迁移并先完成备份。 -### 4. 启动开发服务器 +### 4. 启动 Web 与 API ```bash pnpm dev ``` -打开终端显示的本地地址,默认通常为 `http://localhost:5173`。 +- Web: +- API: +- 健康检查: + +### 演示账号 + +| 角色 | 邮箱 | 密码 | 默认入口 | +| ------ | --------------------- | ---------- | ------------------------------ | +| 学生 | `student@example.com` | `Demo123!` | `/student/home` | +| 教师 | `teacher@example.com` | `Demo123!` | `/teacher/home` | +| 管理员 | `admin@example.com` | `Demo123!` | `/administrator/courseDocking` | -## 🔧 环境变量 +这些账号由本地 seed 创建,不能用于生产。生产镜像固定 `SEED_ON_START=false`。 -| 变量 | 用途 | 未配置时的代码回退 | -| -------------------- | ------------------------ | ---------------------------------------------- | -| `VITE_API_BASE_URL` | REST API 根地址 | `/`(当前站点同源) | -| `VITE_FORUM_API_URL` | 关键词生成话轮的完整接口 | `/process_words` | -| `VITE_WEBSOCKET_URL` | 聊天与课堂信令 WebSocket | 当前站点的 `/websocket`,自动选择 `ws` / `wss` | +## 环境变量 -`.env.example` 提供了本地开发示例。所有以 `VITE_` 开头的变量都会进入浏览器构建产物,禁止在其中存放密码、私钥或长期有效的服务端密钥。 +完整的本地模板见 [`.env.example`](./.env.example),生产配置与密钥要求见 [`docs/operations.md`](./docs/operations.md)。 -## 📜 常用命令 +| 变量 | 用途 | 默认或边界 | +| ------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------- | +| `VITE_API_BASE_URL` | 浏览器 API 前缀 | `/api/v1`;推荐保持同源 | +| `HOST` / `PORT` | API 监听地址与端口 | `127.0.0.1` / `7777` | +| `DATABASE_PATH` | SQLite 文件 | `.data/platform.db` | +| `UPLOAD_DIR` | 上传文件目录 | `.data/uploads` | +| `UPLOAD_OWNER_QUOTA_BYTES` / `UPLOAD_TOTAL_QUOTA_BYTES` | 用户与平台上传总配额 | 默认 250 MiB / 5 GiB;单实例内原子预留 | +| `UPLOAD_MAX_CONCURRENT` | 全局并发上传槽位 | 默认 `4`,最大 `32` | +| `APP_ORIGIN` | 允许携带 Cookie 写请求的精确来源 | 开发为 `http://localhost:5173`;生产必须是公网 HTTPS origin | +| `SESSION_TTL_HOURS` | 会话有效期 | `12` | +| `VERIFICATION_CODE_SECRET` | 注册验证码 HMAC 密钥 | 生产必须设置高熵、稳定值 | +| `SMTP_URL` / `MAIL_FROM` | 注册验证码邮件 | 生产开放注册时必需 | +| `AI_API_URL` / `AI_API_KEY` | 可选外部对话生成器 | 未配置或失败时使用本地确定性实现 | +| `TURN_URL` / `TURN_USERNAME` / `TURN_CREDENTIAL` | 可选 WebRTC 中继 | 严格 NAT 和可靠生产课堂需要 | +| `SEED_ON_START` | 启动时写入演示数据 | 开发默认开启;生产 Compose 固定关闭 | +| `SECURE_COOKIES` / `ALLOW_BEARER_AUTH` / `TRUST_PROXY` | Cookie、Bearer 兼容与代理边界 | 生产 Compose 启用 Secure Cookie、关闭 Bearer;代理信任必须按拓扑设置 | +| `ADMIN_EMAIL` / `ADMIN_PASSWORD` / `ADMIN_DISPLAY_NAME` | 一次性首个管理员引导 | 只在运行引导命令时临时注入,不应保存在生产环境文件 | -| 命令 | 说明 | -| ------------------- | --------------------------------- | -| `pnpm dev` | 启动 Vite 开发服务器 | -| `pnpm build` | 生成生产构建到 `dist/` | -| `pnpm preview` | 本地预览生产构建 | -| `pnpm lint` | 自动修复可修复的 ESLint 问题 | -| `pnpm lint:check` | 只读执行 ESLint 检查 | -| `pnpm format` | 格式化 `src/` | -| `pnpm format:check` | 检查 `src/` 的 Prettier 格式 | -| `pnpm check` | 依次执行 lint、格式检查和生产构建 | +所有 `VITE_` 变量会进入浏览器构建产物,禁止在其中存放密钥。生产注册必须同时具备 SMTP 和验证码密钥;开发模式在未配置 SMTP 时会返回仅供本机测试的验证码。 -提交代码前建议执行: +## 数据库迁移与管理员引导 + +数据库打开时会读取 `schema_migrations` 台账,并在事务中依次应用 [`server/db/database.js`](./server/db/database.js) 中的迁移。也可以显式执行: ```bash -pnpm check +pnpm db:migrate ``` -## 📁 项目结构 +生产环境不创建演示管理员。首次部署后,临时注入 `ADMIN_EMAIL`、`ADMIN_PASSWORD` 和 `ADMIN_DISPLAY_NAME`,再执行: -```text -International_Chinese_Platform/ -├── .github/ -│ └── workflows/ci.yml # GitHub Actions 质量门禁 -├── public/ # 公共静态资源 -├── src/ -│ ├── api/ # 学生、用户与通用 API 封装 -│ ├── assets/ # 样式、图标与课程媒体 -│ ├── components/ # 基础组件、领域组件与滴滴上课组件 -│ ├── config/ # REST / Forum / WebSocket 运行时配置 -│ ├── i18n/ # 国际化实例与中英文词条 -│ ├── router/ # 多角色嵌套路由和守卫 -│ ├── stores/ # Pinia 会话、学生与管理员状态 -│ ├── utils/ # Axios 与 WebSocket 工具 -│ └── views/ -│ ├── student/ # 学生端页面 -│ ├── teacher/ # 教师端页面 -│ ├── administrator/ # 管理员端页面 -│ ├── liveClass/ # 直播课堂与 WebRTC 组合式函数 -│ └── login/ # 登录与注册界面 -├── .env.example # 本地服务地址示例 -├── package.json # 脚本、依赖和运行时要求 -├── pnpm-lock.yaml # 可复现依赖锁文件 -└── vite.config.js # Vite 配置 +```bash +pnpm admin:bootstrap ``` -## ✅ 质量保障 +管理员密码必须至少 12 位,并包含大写字母、小写字母、数字和特殊字符。命令只允许创建首个管理员;检测到已有管理员时会拒绝覆盖。Docker 中的安全注入方式、备份、迁移与恢复流程见[运维手册](./docs/operations.md)。 -| 层级 | 门禁 | -| ---------- | -------------------------------------------------------- | -| 静态质量 | ESLint 只读检查 | -| 格式一致性 | Prettier 检查 | -| 可构建性 | Vite 生产构建 | -| 持续集成 | Ubuntu + Node.js 20 + pnpm 8.15.9 + frozen lockfile | -| 浏览器验证 | 关键登录、角色重定向、学生首页和数字人路由的人工冒烟检查 | +## 测试与质量门禁 -GitHub Actions 会在 Pull Request 以及推送到 `main` 时运行同一套安装、检查和构建流程。项目目前尚未配置自动化单元、组件或端到端测试,因此关键业务改动仍应补充浏览器验证。 +```bash +pnpm test:api +pnpm check +``` -## 🔗 仓库整合 +| 层级 | 当前覆盖 | +| ------------ | ------------------------------------------------------------------------------------------------------------------------ | +| API 集成测试 | **53 / 53 通过**;覆盖认证、教师审核、课程、预约与课堂、作业、迁移、邮件、流式上传与配额、通知、资料、对话和实时房间隔离 | +| 浏览器 E2E | **4 条跨角色流程通过**;真实 UI 登录三个角色,验证教师认证撤销/恢复、课程审核、预约/课堂完成、作业提交/评分 | +| 静态质量 | `pnpm check` 依次运行 ESLint、Prettier、API 测试和生产构建 | +| 持续集成 | 独立 Validate 与 Cross-role browser E2E 任务;Node 24、pnpm 11.9.0、Python 3.12、冻结锁文件和隔离数据库 | -本仓库是项目唯一的主维护仓库: +浏览器测试使用锁定版本的 Python Playwright。先安装测试依赖并构建生产前端: -> [computersciencefreshmen/International_Chinese_Platform](https://github.com/computersciencefreshmen/International_Chinese_Platform) +```bash +python -m pip install --requirement e2e/requirements.txt +python -m playwright install chromium +pnpm build +``` + +在第一个终端启动隔离的生产模式服务。macOS / Linux: -项目早期代码曾分散在: +```bash +mkdir -p .data +rm -f .data/e2e.db .data/e2e.db-shm .data/e2e.db-wal +NODE_ENV=production \ +SEED_ON_START=true \ +SECURE_COOKIES=false \ +APP_ORIGIN=http://localhost:7777 \ +VERIFICATION_CODE_SECRET=e2e-only-verification-secret-32-characters \ +DATABASE_PATH=.data/e2e.db \ +pnpm start +``` -- [computersciencefreshmen/vue3-project-initialization](https://github.com/computersciencefreshmen/vue3-project-initialization) -- [computersciencefreshmen/project](https://github.com/computersciencefreshmen/project) +Windows PowerShell: + +```powershell +New-Item -ItemType Directory -Force .data | Out-Null +Remove-Item -Force -ErrorAction SilentlyContinue .data/e2e.db, .data/e2e.db-shm, .data/e2e.db-wal +$env:NODE_ENV = 'production' +$env:SEED_ON_START = 'true' +$env:SECURE_COOKIES = 'false' +$env:APP_ORIGIN = 'http://localhost:7777' +$env:VERIFICATION_CODE_SECRET = 'e2e-only-verification-secret-32-characters' +$env:DATABASE_PATH = '.data/e2e.db' +pnpm start +``` + +在第二个终端执行: + +```bash +python e2e/test_workflows.py +``` -旧仓库的有效提交历史已通过标准 Git 合并提交进入当前 `main`,包括原本无共同祖先的初始化历史。后续代码、Issue、文档和发布均应以本仓库为准;旧仓库只作为历史参考。 +脚本默认访问 `http://localhost:7777`;可通过 `E2E_BASE_URL` 改写地址,通过 `E2E_BROWSER_EXECUTABLE` 使用本机 Chrome。失败时截图、trace 和浏览器日志写入 `test-results/e2e/`。CI 会自行构建、安装 Chromium、启动独立 seed 数据库并上传失败产物。 -## 🗺️ 路线图 +API 测试使用临时数据库,不依赖演示数据库。仓库当前不宣称存在公开托管的 Demo;可复现验证入口是本地启动、自动测试和 Docker 部署。 -- [x] 统一三个仓库历史和主维护入口 -- [x] 建立学生、教师、管理员三端路由结构 -- [x] 统一 REST、话轮服务和 WebSocket 运行时配置 -- [x] 建立 ESLint、Prettier、生产构建和 GitHub Actions 门禁 -- [ ] 对齐完整后端契约,将静态课程、教师和管理数据替换为真实数据 -- [ ] 完成 WebRTC 房间、信令、TURN 和课堂聊天协议 -- [ ] 接入真实数字人、AI、ASR 与 TTS 服务 -- [ ] 完成全量中英文界面国际化 -- [ ] 增加单元、组件和端到端自动化测试 -- [ ] 补充生产部署、监控、安全基线和开源许可证 +## Docker 同源部署 -## 🚢 部署说明 +生产镜像由 Node 24 多阶段构建生成,并以非 root 用户运行。准备好受限制的 `.env.production` 后: ```bash -pnpm build +docker compose --env-file .env.production build --pull +docker compose --env-file .env.production up -d +docker compose --env-file .env.production ps ``` -生产构建输出到 `dist/`。部署时需要: +Compose 默认只把应用发布到宿主机 `127.0.0.1:7777`,并将数据库和上传分别放入持久卷。反向代理必须让页面、`/api/v1`、上传文件和 `/ws` 共享同一公网 origin;不要把 API 单独暴露到另一个域名。 + +上线前请完整阅读[生产部署与运维手册](./docs/operations.md)。其中包含 TLS 代理示例、管理员引导、迁移、冷备份、恢复演练、监控和事故处理,而不把“构建成功”等同于“生产就绪”。 + +## 项目结构 -1. 在构建阶段注入正确的 `VITE_*` 环境变量。 -2. 为 Vue Router history 模式配置 SPA 回退,将未知路由返回 `index.html`。 -3. 使用 HTTPS,并为实时服务使用 WSS;同时正确配置后端 CORS。 -4. 为生产直播课堂提供经过认证的信令服务、TURN 服务和媒体安全策略。 -5. 在发布前联调登录、课程、作业、话轮和实时课堂的完整端到端流程。 +```text +International_Chinese_Platform/ +├── src/ # Vue 3 客户端与三个角色工作区 +│ ├── api/ # 同源 API 调用 +│ ├── components/ # 账户、服务与基础组件 +│ ├── router/ # 会话恢复与角色路由守卫 +│ ├── stores/ # Pinia 状态 +│ └── views/ # 学生、教师、管理员与课堂页面 +├── server/ # Fastify 后端(不是外部依赖) +│ ├── app.js # 应用组合根与全部路由注册 +│ ├── routes/ # 认证及领域 API / WebSocket +│ ├── db/ # Schema、迁移、seed、reset、管理员引导 +│ ├── services/ # SMTP 与 AI 适配器 +│ └── test/ # 53 项 API 集成测试 +├── e2e/ # Python Playwright 跨角色浏览器测试 +├── docs/ +│ ├── adr/ # 架构决策记录 +│ ├── plans/ # 完整化设计与实施计划 +│ └── operations.md # 生产部署、备份与恢复 +├── .github/workflows/ci.yml # 质量门禁与浏览器 E2E +├── Dockerfile # Node 24 多阶段生产镜像 +├── docker-compose.yml # 单实例、双持久卷部署 +├── SECURITY.md # 漏洞报告与安全期望 +└── .env.example # 本地配置模板 +``` -## 🤝 参与贡献 +## API 分组 + +所有 REST 接口使用 `/api/v1` 前缀,并返回统一的 `{ code, msg, data }` 结构。实现以 [`server/app.js`](./server/app.js) 的注册列表为准。 + +| 分组 | 主要路径 | 能力 | +| ------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| System | `/health`, `/ready` | 存活与数据库就绪检查 | +| Auth | `/auth/verification-code`, `/auth/register`, `/auth/login`, `/auth/logout`, `/auth/session` | 验证码、账号与会话生命周期 | +| Profile & Teachers | `/me`, `/me/password`, `/teachers` | 个人资料、教师专业档案与发现 | +| Courses & Admin | `/courses`, `/admin/course-reviews`, `/admin/teacher-verifications`, `/admin/metrics` | 课程 CRUD、课程与教师审核、运营指标 | +| Appointments & Classrooms | `/appointments`, `/classrooms/:id/*` | 预约响应、冲突校验、加入信息、完成课堂 | +| Assignments | `/courses/:id/assignments`, `/assignments`, `/submissions`, `/me/submissions` | 作业生命周期、学生提交与教师评分 | +| Files | `/files`, `/files/:id/content` | 分类上传、内容校验、元数据与删除 | +| Dialogues | `/dialogues`, `/dialogues/:id/messages` | 持久化对话生成与继续练习 | +| Notifications | `/notifications` | 分页、未读状态与全部已读 | +| Realtime | `/classrooms/:id/ticket`, `/classrooms/:id/messages`, `/ws/classroom` | 一次性票据、历史消息、在线状态、聊天与 RTC 信令 | + +## 安全设计 + +- 密码使用参数化 scrypt 哈希;登录路径执行无用户差异的密码校验以降低枚举信号。 +- 浏览器只持有 HttpOnly、SameSite 会话 Cookie;数据库保存不透明令牌的摘要,登出、改密和过期均会撤销会话。 +- Cookie 写请求校验 `Origin`,生产 Compose 默认启用 Secure Cookie 并关闭 Bearer 认证。 +- 领域写操作按角色和资源关系授权,业务状态转换在服务端再次检查;非公开文件的内容、元数据和删除均限 owner/admin。 +- 未认证教师不会进入公开发现且不能被预约;管理员的批准/撤销会通知教师并写审计,教师修改资料会自动回到待审核状态。 +- Zod 校验输入;Helmet 设置安全响应头;Fastify 对请求和敏感接口执行速率限制。 +- 上传采用私有临时文件流式落盘、增量哈希、签名字节校验和同卷原子改名,并执行角色、大小、用户/平台配额与全局并发限制;这不是恶意内容扫描。 +- 验证码以摘要保存、限制尝试次数并一次性消费;邮件发送失败不会创建可用注册流程。 +- 关键审核、预约、作业和管理员引导操作写入审计日志。 + +课堂票据签发、握手、历史读取、每个实时事件以及周期性连接清理都会重新校验会话、账号、课堂状态和参与者关系;权限失效的连接会被关闭。非公开课程视频和材料当前只允许 owner/admin 读取;由于正式选课不在本项目范围内,尚未建立学生的课程资料授权关系。需要分发私有课程资料的部署,应先补充选课或资源授权模型。 + +这些措施不等于第三方安全认证。部署者仍需管理 TLS、密钥、依赖更新、日志、数据保留和备份访问。漏洞请按 [`SECURITY.md`](./SECURITY.md) 私密报告,不要在公开 Issue 中披露细节。 + +## 生产边界 + +| 边界 | 当前设计 | 扩展方向 | +| -------- | ---------------------------------------------------------------- | ----------------------------------------------------------- | +| 数据库 | SQLite WAL,单应用副本 | 多实例前迁移到服务型数据库并重新评估事务与迁移策略 | +| 实时课堂 | 单进程 WebSocket 房间,WebRTC 点对点媒体 | 多节点需要共享 presence/pub-sub、粘性路由和独立媒体基础设施 | +| 网络穿透 | TURN 为可选配置 | 严格 NAT 或企业网络的生产课堂必须提供受监控的 TURN | +| 文件 | 本地持久卷;默认用户 250 MiB、平台 5 GiB、4 个并发上传槽位 | 大规模部署可迁移对象存储并补充扫描、生命周期与 CDN | +| 邮件 | SMTP 适配器 | 生产注册必须配置可交付的邮件服务并监控失败率 | +| AI | 单一 HTTP 适配器 + 本地确定性降级 | 可增加供应商策略、成本控制、内容安全和可观测性 | +| 教学商务 | 平台内管理员教师认证;价格与容量仍是资料字段 | 支付、正式选课及证书颁发方联网核验/OCR 需独立领域设计 | +| 课堂媒体 | 实时音视频与信令 | 录制、转码、回放和内容审核不在当前实现中 | +| 持续授权 | 票据、握手、每个事件及周期清理均复验权限;私有材料限 owner/admin | 多实例时增加共享撤权信号,并补充基于选课关系的学生资料权限 | + + + +## 文档索引 + +- [`README.md`](./README.md) — 中文主文档。 +- [`README.en.md`](./README.en.md) — 等价英文文档。 +- [`docs/operations.md`](./docs/operations.md) — Docker 生产部署、反向代理、迁移、备份、恢复与监控。 +- [`SECURITY.md`](./SECURITY.md) — 漏洞报告流程与部署安全期望。 +- [`docs/adr/0001-self-contained-modular-monolith.md`](./docs/adr/0001-self-contained-modular-monolith.md) — 自包含模块化单体决策。 +- [`docs/adr/0002-server-side-opaque-sessions.md`](./docs/adr/0002-server-side-opaque-sessions.md) — 服务端不透明会话决策。 +- [`docs/plans/2026-07-19-full-stack-completion-design.md`](./docs/plans/2026-07-19-full-stack-completion-design.md) — 完整化设计说明。 +- [`docs/plans/2026-07-19-full-stack-completion.md`](./docs/plans/2026-07-19-full-stack-completion.md) — 实施计划与验收范围。 + +## 唯一主仓库 + +本项目后续代码、Issue、文档与发布只以以下仓库为准: -欢迎通过 Issue 和 Pull Request 改进项目: +> [computersciencefreshmen/International_Chinese_Platform](https://github.com/computersciencefreshmen/International_Chinese_Platform) + +早期历史曾分散在 [`vue3-project-initialization`](https://github.com/computersciencefreshmen/vue3-project-initialization) 和一个名为 `project` 的仓库;后者当前已无法从 GitHub 访问,因此不再保留失效链接。有效历史已经合并到当前仓库;可访问的旧仓库只作为历史参考,不应继续接收功能提交。 + +## 参与开发 + +从最新 `main` 创建聚焦分支,修改功能时同步更新测试和文档。提交前运行: + +```bash +pnpm check +``` -1. 从最新 `main` 创建语义清晰的功能分支。 -2. 保持改动聚焦,并同步更新相关文档。 -3. 提交前运行 `pnpm check`。 -4. 创建 Pull Request,等待 CI 通过后使用标准合并提交并入 `main`。 +Pull Request 应在 CI 通过后合并回 `main`,不要让功能长期停留在个人分支。 -## 📄 许可证 +## 许可证 -本仓库目前尚未声明开源许可证。在添加明确许可证之前,请不要假定代码已获得自由复制、分发或商用授权。 +本仓库目前**没有声明开源许可证**。在仓库所有者添加明确许可证之前,请不要假定代码已被授权复制、分发、修改或用于商业用途。
-

Built with ❤️ for international Chinese education.

- ⬆ 回到顶部 +

Built for complete, inspectable international Chinese teaching workflows.

+ 返回顶部
diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..af0c3a7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,32 @@ +# Security Policy + +## Supported versions + +This project is maintained from the default branch. Security fixes target the latest commit on `main`; historical snapshots and private forks are not supported release lines. + +## Reporting a vulnerability + +Please do **not** disclose a suspected vulnerability in a public issue, discussion, pull request, screenshot, or demo environment. + +1. Open a private report through [GitHub Security Advisories](https://github.com/computersciencefreshmen/International_Chinese_Platform/security/advisories/new). +2. If private reporting is unavailable, open a public issue that contains only a request for a private contact channel. Do not include exploit details, credentials, personal data, or affected URLs. +3. Include the affected commit or deployment version, prerequisites, reproducible steps, impact, and any suggested mitigation. Use synthetic data only. + +We aim to acknowledge a report within 72 hours, complete initial triage within 7 days, and provide an update at least every 7 days until resolution. These targets are best-effort for a community project and are not a service-level agreement. + +Please allow time for a fix and coordinated disclosure. After remediation, the project may publish an advisory crediting the reporter, unless anonymity is requested. This repository does not currently operate a paid bug-bounty program. + +## Security expectations for operators + +- Serve the site only through HTTPS and keep the browser, API, uploads, and WebSocket endpoint on one public origin. +- Never enable demo seeding in production and never bake administrator credentials into an image or Compose file. +- Store `VERIFICATION_CODE_SECRET`, SMTP, AI, and TURN credentials outside version control; grant access only to deployment operators and rotate them after suspected exposure. +- Keep secure, HttpOnly session cookies enabled, disable bearer authentication unless an audited non-browser client requires it, and configure the trusted proxy boundary deliberately. +- Back up both the SQLite database and uploaded files, encrypt backups, test restores, and restrict backup access. +- Rebuild frequently from the lockfile and review dependency/security alerts before deploying. + +Deployment hardening, backup, restore, migration, and secret-handling procedures are documented in [docs/operations.md](docs/operations.md). + +## Scope and safe research + +Good-faith reports about authentication, authorization, sensitive-data exposure, injection, file upload/download controls, session handling, WebSocket isolation, or deployment defaults are welcome. Do not access another person's data, degrade a service, send unsolicited messages, or retain data obtained during testing. Stop testing and report immediately if real personal data or credentials are encountered. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..be45840 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,76 @@ +name: international-chinese-platform + +services: + app: + image: ${IMAGE_NAME:-international-chinese-platform:local} + build: + context: . + target: runtime + args: + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api/v1} + restart: unless-stopped + init: true + ports: + - "${BIND_ADDRESS:-127.0.0.1}:${APP_PORT:-7777}:7777" + environment: + NODE_ENV: production + HOST: 0.0.0.0 + PORT: 7777 + DATABASE_PATH: /app/data/platform.db + UPLOAD_DIR: /app/uploads + UPLOAD_OWNER_QUOTA_BYTES: ${UPLOAD_OWNER_QUOTA_BYTES:-262144000} + UPLOAD_TOTAL_QUOTA_BYTES: ${UPLOAD_TOTAL_QUOTA_BYTES:-5368709120} + UPLOAD_MAX_CONCURRENT: ${UPLOAD_MAX_CONCURRENT:-4} + DIST_DIR: /app/dist + APP_ORIGIN: "${APP_ORIGIN:?Set APP_ORIGIN to the public HTTPS origin}" + SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-12} + VERIFICATION_CODE_SECRET: "${VERIFICATION_CODE_SECRET:?Set a stable random verification secret}" + SECURE_COOKIES: ${SECURE_COOKIES:-true} + ALLOW_BEARER_AUTH: ${ALLOW_BEARER_AUTH:-false} + TRUST_PROXY: ${TRUST_PROXY:-true} + LOGGER: ${LOGGER:-true} + SEED_ON_START: "false" + BODY_LIMIT_BYTES: ${BODY_LIMIT_BYTES:-1048576} + RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-300} + RATE_LIMIT_WINDOW: ${RATE_LIMIT_WINDOW:-1 minute} + SMTP_URL: ${SMTP_URL:-} + MAIL_FROM: ${MAIL_FROM:-} + AI_API_URL: ${AI_API_URL:-} + AI_API_KEY: ${AI_API_KEY:-} + TURN_URL: ${TURN_URL:-} + TURN_USERNAME: ${TURN_USERNAME:-} + TURN_CREDENTIAL: ${TURN_CREDENTIAL:-} + volumes: + - platform-database:/app/data + - platform-uploads:/app/uploads + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,nodev,size=64m + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: + - CMD + - node + - -e + - >- + fetch('http://127.0.0.1:7777/api/v1/ready').then((response) => { + if (!response.ok) process.exit(1) }).catch(() => process.exit(1)) + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 + stop_grace_period: 20s + logging: + driver: json-file + options: + max-size: 10m + max-file: "3" + +volumes: + platform-database: + name: ${DATABASE_VOLUME_NAME:-international-chinese-platform-database} + platform-uploads: + name: ${UPLOAD_VOLUME_NAME:-international-chinese-platform-uploads} diff --git a/docs/adr/0001-self-contained-modular-monolith.md b/docs/adr/0001-self-contained-modular-monolith.md new file mode 100644 index 0000000..b2cab6b --- /dev/null +++ b/docs/adr/0001-self-contained-modular-monolith.md @@ -0,0 +1,18 @@ +# ADR-0001:采用自包含模块化单体 + +**状态:** Accepted +**日期:** 2026-07-19 + +## Context + +现有仓库只有 Vue 原型,旧后端不可验证。项目需要由一个开发者维护、无需云账号即可演示,同时保留清晰的领域边界和真实数据事务。 + +## Decision + +在当前仓库中增加 Fastify 模块化单体和 SQLite 数据库:开发时 Vite 代理,生产时同源托管。数据库使用 WAL、外键和参数化语句;服务端按身份、课程、预约、作业、课堂、管理模块拆分。 + +## Consequences + +- 正面:一条命令可运行;核心后端代码完全在仓库;跨角色事务易验证;部署成本低。 +- 负面:SQLite 不适合大量并发写入或多实例横向扩容;达到该规模时需要迁移 PostgreSQL。 +- 约束:仓储访问保持集中,不在视图/路由中散落 SQL;外部能力必须通过 provider 接口隔离。 diff --git a/docs/adr/0002-server-side-opaque-sessions.md b/docs/adr/0002-server-side-opaque-sessions.md new file mode 100644 index 0000000..97f087c --- /dev/null +++ b/docs/adr/0002-server-side-opaque-sessions.md @@ -0,0 +1,20 @@ +# ADR-0002:使用服务端 opaque session + +**状态:** Accepted +**日期:** 2026-07-19 + +## Context + +旧原型把长期 token 存入 localStorage,并在部分请求和 WebSocket URL 中传递,容易被 XSS 或访问日志暴露,也没有可靠的服务端撤销机制。 + +## Decision + +登录后签发安全随机 session token,通过 HttpOnly Cookie 携带;SQLite 只保存 token 的 SHA-256 摘要和过期时间。退出登录、修改密码和管理员禁用账号时可立即撤销。WebSocket 使用短期、单用途 ticket。 + +迁移阶段 REST 可接受 `Authorization: Bearer` 作为非浏览器客户端兼容方式,但前端不持久化 token,URL 中禁止出现长期凭据。 + +## Consequences + +- 正面:降低浏览器令牌泄漏风险;会话可撤销;REST 与 WebSocket 权限统一。 +- 负面:写请求必须处理 CSRF/Origin;前端启动时需要恢复会话。 +- 约束:生产环境必须 HTTPS,Cookie 启用 `Secure`;所有角色权限由服务端执行。 diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..f478098 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,218 @@ +# 生产部署与运维手册 + +本手册面向单机 Docker Compose 生产部署。平台是一个 Vue 3 + Fastify + SQLite 的模块化单体:同一进程提供编译后的前端、`/api/v1` API(包括受权限控制的文件内容)和 `/ws` 实时连接。 + +> SQLite 部署应始终保持一个应用副本。不要让多个容器通过网络文件系统共享同一数据库。如果需要水平扩展,应先将持久层迁移到适合多实例的数据库。 + +## 1. 生产拓扑与持久数据 + +Compose 默认只将 `127.0.0.1:7777` 暴露给宿主机,应由 Nginx、Caddy 或云负载均衡器终止 TLS: + +```text +Internet -> HTTPS reverse proxy -> 127.0.0.1:7777 -> Fastify + |-- /app/data/platform.db + `-- /app/uploads +``` + +两个命名卷分别保存数据库与用户上传文件: + +| Compose 卷(默认物理名) | 容器目录 | 内容 | +| ---------------------------------------------------------------- | -------------- | -------------------------------- | +| `platform-database`(`international-chinese-platform-database`) | `/app/data` | SQLite 主文件及 WAL/SHM 辅助文件 | +| `platform-uploads`(`international-chinese-platform-uploads`) | `/app/uploads` | 头像、作业和课程附件 | + +镜像为多阶段 Node 24 构建,运行阶段不包含编译工具,以 UID/GID `10001` 的非 root 用户运行。Compose 另外启用只读根文件系统、临时 `/tmp`、全 capability 丢弃和 `no-new-privileges`。 + +## 2. 准备生产配置 + +需要 Docker Engine 和 Docker Compose v2。先在部署机上创建不纳入 Git 的 `.env.production`,限制为仅部署用户可读: + +```dotenv +APP_ORIGIN=https://chinese.example.com +APP_PORT=7777 +BIND_ADDRESS=127.0.0.1 + +# 可选:稳定的 Docker 卷名,也可用于恢复时安全切换到新卷 +DATABASE_VOLUME_NAME=international-chinese-platform-database +UPLOAD_VOLUME_NAME=international-chinese-platform-uploads + +# 默认用户 250 MiB、平台 5 GiB、最多 4 个并发上传;按容量规划调整 +UPLOAD_OWNER_QUOTA_BYTES=262144000 +UPLOAD_TOTAL_QUOTA_BYTES=5368709120 +UPLOAD_MAX_CONCURRENT=4 + +# 至少 32 个 ASCII 字符的稳定随机值;不要在每次发布时更换 +VERIFICATION_CODE_SECRET=replace-with-at-least-32-random-characters + +SESSION_TTL_HOURS=12 +SECURE_COOKIES=true +TRUST_PROXY=true +ALLOW_BEARER_AUTH=false + +# 生产注册验证邮件必需 +SMTP_URL=smtps://username:percent-encoded-password@smtp.example.com:465 +MAIL_FROM=International Chinese Platform + +# 可选外部能力 +AI_API_URL= +AI_API_KEY= +TURN_URL= +TURN_USERNAME= +TURN_CREDENTIAL= +``` + +生成验证码密钥的一种方式是 `openssl rand -hex 32`。请使用密钥管理器或受限制的环境文件;不要把真实值写入镜像、Compose 文件、CI 日志或 shell 历史。注意 Docker 环境变量对具有容器检查权限的运维人员可见。 + +`SEED_ON_START=false` 在生产 Compose 中是固定值:生产容器不会创建演示账号或演示数据。Compose 也不声明任何 `ADMIN_*` 变量。 + +## 3. 构建、启动与验证 + +```bash +docker compose --env-file .env.production build --pull +docker compose --env-file .env.production up -d +docker compose --env-file .env.production ps +docker compose --env-file .env.production logs --tail=100 app +``` + +首次启动会创建 SQLite 文件并自动应用未执行的有序迁移。两个探针的意义不同: + +- `GET /api/v1/health`:进程存活。 +- `GET /api/v1/ready`:应用就绪且数据库可读。Docker 健康检查使用此接口。 + +```bash +curl --fail https://chinese.example.com/api/v1/health +curl --fail https://chinese.example.com/api/v1/ready +``` + +如果使用宿主目录替代命名卷,请先确保目录属于 `10001:10001` 且不对其他用户开放写权限,否则数据库或上传会因权限错误失败。 + +## 4. 反向代理与同源要求 + +浏览器页面、API、上传文件与 WebSocket 必须对外使用完全相同的 scheme、host 和 port。`APP_ORIGIN` 必须精确等于这个公网 origin,不要带路径或末尾斜杠。一个 Nginx 示例如下: + +```nginx +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 443 ssl http2; + server_name chinese.example.com; + + # ssl_certificate /etc/letsencrypt/live/chinese.example.com/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/chinese.example.com/privkey.pem; + + client_max_body_size 50m; + + location / { + proxy_pass http://127.0.0.1:7777; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 3600s; + } +} +``` + +不要单独将 API 发布到另一个 origin。同源部署才能与默认的 HttpOnly/SameSite/Secure 会话 Cookie、来源检查和 WebSocket 路由保持一致。只有反向代理是受信边界时才设置 `TRUST_PROXY=true`,并确保应用端口不可被公网绕过。 + +## 5. 首个管理员引导 + +首次部署就绪后,通过一次性容器执行引导。密码必须至少 12 位,并同时包含大写字母、小写字母、数字和特殊字符。先在当前 shell 中安全注入下列三个变量: + +```text +ADMIN_EMAIL +ADMIN_PASSWORD +ADMIN_DISPLAY_NAME +``` + +然后执行: + +```bash +docker compose --env-file .env.production run --rm \ + -e ADMIN_EMAIL -e ADMIN_PASSWORD -e ADMIN_DISPLAY_NAME \ + app node server/db/bootstrap-admin.js +``` + +不要把密码直接写在命令行或 `.env.production` 中。成功后立即从 shell 清除三个变量。引导程序只允许创建第一个管理员;如果已存在管理员,它会拒绝覆盖。 + +## 6. 数据库迁移与发布 + +应用在打开数据库时检查 `schema_migrations` 台账,并在事务中按顺序应用新迁移。也可以在维护窗口显式执行: + +```bash +docker compose --env-file .env.production stop app +docker compose --env-file .env.production run --rm app node server/db/migrate.js +docker compose --env-file .env.production up -d +``` + +推荐发布流程: + +1. 验证新镜像来源和依赖审计结果。 +2. 创建数据库与上传文件的一致备份。 +3. 在维护窗口停止应用并执行迁移。 +4. 启动新镜像,确认 `/ready`、登录、文件访问和 WebSocket 连接。 +5. 保留旧镜像与发布前备份,直到观察窗口结束。 + +当前迁移是向前迁移,不提供自动 down migration。需要回滚数据结构时,请还原发布前备份,而不是手工删改生产表。 + +## 7. 备份 + +备份必须同时包含 `/app/data` 和 `/app/uploads`。只备份 SQLite 会丢失附件;只备份上传目录会丢失文件索引和权限关系。 + +最简单可靠的方式是在短维护窗口执行冷备份: + +1. 确认磁盘空间,创建带 UTC 时间戳的宿主备份目录。 +2. 执行 `docker compose --env-file .env.production stop app`,等待优雅停机完成。 +3. 用 `docker compose --env-file .env.production cp app:/app/data/. /database` 复制完整数据目录。 +4. 用 `docker compose --env-file .env.production cp app:/app/uploads/. /uploads` 复制完整上传目录。 +5. 执行 `docker compose --env-file .env.production start app`,并确认 `/ready`。 +6. 压缩备份,生成 SHA-256 校验和,使用独立密钥加密,然后复制到另一存储位置。 + +不要在应用写入期间用普通文件复制直接抓取 `platform.db`;WAL 模式下这样的副本可能不一致。如果业务无法接受短暂停机,请引入经验证的 SQLite 在线备份方案,并定期做恢复演练。 + +建议至少保留每日 7 份、每周 4 份和每月 6 份,实际策略应按数据保留法规和可接受的 RPO/RTO 确定。 + +## 8. 恢复演练 + +先在隔离环境演练,再恢复生产。恢复前必须再做一份当前数据的应急备份。 + +1. 校验备份的 SHA-256、解密并确认包含 `database/platform.db` 和 `uploads/`。 +2. 停止应用并做当前数据的应急备份,然后执行 `docker compose --env-file .env.production down`。不要附加 `--volumes`;原卷将作为回退点保留。 +3. 复制一份 `.env.production` 为受限制的 `.env.restore`,只将 `DATABASE_VOLUME_NAME` 和 `UPLOAD_VOLUME_NAME` 改为新的、带恢复时间戳的名称。 +4. 执行 `docker compose --env-file .env.restore create app`,让 Docker 创建干净卷和已停止的容器。 +5. 用 `docker compose --env-file .env.restore cp /database/. app:/app/data` 和 `docker compose --env-file .env.restore cp /uploads/. app:/app/uploads` 导入备份。 +6. 如果复制改变了所有者,执行一次性权限修复:`docker compose --env-file .env.restore run --rm --user 0 --cap-add CHOWN --cap-add DAC_OVERRIDE --entrypoint chown app -R 10001:10001 /app/data /app/uploads`。 +7. 执行 `docker compose --env-file .env.restore run --rm app node server/db/migrate.js`,再执行 `docker compose --env-file .env.restore up -d`。 +8. 检查 `/ready`,并用最小冒烟测试验证用户数、课程、预约、作业、附件下载与实时教室。确认恢复点可用后,再将受管理的生产配置切换到这两个新卷。 + +复制回已有卷会改变生产数据。每次操作前都要记录完整卷名、备份标识和操作人,并采用双人复核。 + +## 9. 外部适配器与密钥 + +| 变量 | 用途 | 运维要求 | +| --------------------------- | -------------- | ------------------------------------------------------- | +| `VERIFICATION_CODE_SECRET` | 验证码 HMAC | 高熵、稳定保存;轮换会使待使用验证码失效 | +| `SMTP_URL` / `MAIL_FROM` | 注册邮件 | 生产注册必需;URL 内密码应正确百分号编码 | +| `AI_API_URL` / `AI_API_KEY` | 服务端 AI 对话 | 只允许 HTTPS 且可信的供应商;确认数据保留条款 | +| `TURN_URL` / 用户名 / 凭据 | WebRTC 中继 | TURN 凭据会发给授权教室客户端,应限权、轮换并监控流量 | +| `UPLOAD_*` 配额与并发 | 上传资源边界 | 按持久卷容量设置平台/用户上限;监控 503、507 与剩余空间 | + +外部 AI 未配置时,核心教学工作流仍可运行;TURN 未配置时,直连 WebRTC 可能在严格 NAT 或企业网络中失败。SMTP 未配置时,生产环境不会回传开发验证码,新用户注册将不可用。 + +## 10. 监控与事故处理 + +至少监控以下信号: + +- `/ready` 成功率和延迟; +- 容器重启次数、CPU、内存和文件句柄; +- 数据卷剩余空间、SQLite 文件与 WAL 增长; +- HTTP 5xx/429、邮件发送失败、AI 适配器失败和 WebSocket 异常断开; +- 备份完成时间、大小、校验和与恢复演练结果。 + +Compose 已将容器 JSON 日志轮转限制为 3 个、每个 10 MB。数据卷不在该限制内,需要单独监控。故障时先保全日志和数据副本,再执行修复;涉及漏洞或凭据泄露时,按根目录 [SECURITY.md](../SECURITY.md) 进行私密报告和协调披露。 diff --git a/docs/plans/2026-07-19-full-stack-completion-design.md b/docs/plans/2026-07-19-full-stack-completion-design.md new file mode 100644 index 0000000..7f3712b --- /dev/null +++ b/docs/plans/2026-07-19-full-stack-completion-design.md @@ -0,0 +1,102 @@ +# 国际中文教育平台全栈补全设计 + +**状态:** 已接受 +**日期:** 2026-07-19 +**目标:** 把现有 Vue 多角色原型补成仓库自包含、可重复演示、可自动验收的作品级全栈项目。 + +## 1. 完整性的边界 + +本项目的“完整”指: + +- 不依赖已经失效或无法验证的旧后端;克隆仓库后可在本机初始化、启动和重置数据。 +- 学生、教师、管理员三个角色共享同一持久化数据,并完成跨角色业务闭环。 +- 核心接口、权限、状态变化、实时课堂文本能力和失败状态均有自动化测试。 +- README 如实区分“仓库内已经实现”“可替换的外部增强”和“生产部署仍需配置”的能力。 + +首版不承诺真实支付、全球可用的多人音视频 SFU、视频转码/CDN、生产级邮件送达或真实 AI 数字人。这些能力通过适配器和清晰边界保留扩展点。 + +## 2. 方案比较与决定 + +### 方案 A:NestJS + PostgreSQL + Docker + +模块边界最强,适合团队和生产扩展;但会同时引入框架迁移、容器数据库、ORM 和前端目录重排,显著提高当前单人作品的启动与维护成本。 + +### 方案 B:Supabase + +认证、数据库、存储和实时能力上线快;但核心后端由平台提供,不利于说明“后端由自己完整实现”,离线演示也更依赖外部环境。 + +### 方案 C:Fastify 模块化单体 + SQLite(采用) + +保留现有 Vue 工程,在同一仓库新增 Node API。SQLite 负责真实关系数据和事务,Fastify 提供 REST、静态资源与 WebSocket。该方案无需云账号或数据库服务,最适合当前作品级完整性目标;未来可在仓储层替换 PostgreSQL。 + +## 3. 系统结构 + +```text +Vue 3 SPA + ├─ /api/v1/* ──────────────┐ + └─ /ws/classroom?ticket=... ─┤ + ▼ +Fastify 模块化单体 + ├─ auth / profiles + ├─ courses / reviews + ├─ appointments / classrooms + ├─ assignments / submissions + ├─ notifications / metrics + ├─ dialogue provider + └─ local file provider + │ + ▼ + SQLite (WAL) +``` + +开发环境由 Vite 把 `/api` 和 `/ws` 代理到 Fastify;生产环境由 Fastify 同源托管 `dist/`,文件内容通过受权限控制的 `/api/v1/files/:id/content` 提供。外部 AI、SMTP、对象存储和 TURN 均不是核心流程的硬依赖。 + +## 4. 核心领域和状态机 + +- `users`、`sessions`、`verification_codes`:三角色身份、资料和会话。 +- `courses`、`course_reviews`:教师创建 `draft -> pending -> published/rejected`,管理员记录审核意见。 +- `appointments`、`classrooms`:学生预约 `pending -> accepted/rejected/cancelled/completed`;接受后生成课堂房间。 +- `assignments`、`assignment_questions`、`submissions`:学生提交,教师批阅 `submitted -> graded`,成绩回显给学生。 +- `chat_messages`、`notifications`、`audit_logs`:课堂消息、业务通知和关键管理操作证据。 + +所有主键使用 UUID;所有时间存 ISO-8601 UTC;关键外键、唯一约束和查询索引在迁移中创建。预约接受与课程审核使用事务,避免部分更新。 + +## 5. API 和兼容策略 + +新接口统一在 `/api/v1`: + +- 身份:`/auth/login`、`/auth/register`、`/auth/session`、`/auth/logout`、`/me`。 +- 学生:课程、教师、预约、作业提交和成绩。 +- 教师:预约处理、课程 CRUD/提交审核、作业批阅。 +- 管理员:待审课程、审核动作、指标和审计日志。 +- 课堂:加入接口签发短期 WebSocket ticket;实时协议统一为 `chat.message`、`hand.raise`、`presence.*`、`rtc.*` 和 `ping/pong`。 + +返回格式统一为 `{ code, msg, data }`。前端一次性迁移到新契约;旧路径只在必要时提供薄兼容层,不复制旧后端的不一致认证方式。 + +## 6. 安全设计 + +- 密码使用 Node `crypto.scrypt` 加随机盐;不记录密码、验证码、Cookie 或 ticket。 +- 会话 token 使用安全随机数,数据库只保存 SHA-256 摘要;浏览器通过 `HttpOnly`、`SameSite=Lax` Cookie 携带。 +- 每个业务端点校验身份、角色与资源归属;管理员动作写入 `audit_logs`。 +- 所有输入使用完整 JSON Schema/Zod 校验;SQL 仅使用参数化 prepared statements。 +- 登录、验证码和注册限流;写请求校验 Origin;错误响应不泄漏堆栈。 +- 上传限制类型、大小和魔数,服务端生成存储名,禁止用户输入参与磁盘路径。 +- WebSocket ticket 短期、单用途,并再次校验课堂参与资格;SDP/ICE 只转发不持久化。 + +## 7. 失败与降级 + +- 数据库不可写:健康检查报告失败,API 返回稳定错误码,前端提供重试。 +- 外部 AI 未配置:使用确定性的本地话轮生成器,核心演示不受影响。 +- 邮件未配置:开发环境在界面返回一次性演示验证码;生产环境必须配置 SMTP。 +- 摄像头、麦克风或 TURN 不可用:文本聊天、举手和课堂参与列表仍可用,界面明确显示音视频限制。 +- WebSocket 断线:指数退避重连,历史消息通过 REST 补齐。 + +## 8. 可验收闭环 + +1. 三个演示账号登录成功,越权 API 返回 403,刷新后会话恢复。 +2. 教师创建课程并提交审核;管理员驳回;教师修改后再次提交;管理员通过;学生列表立即可见。 +3. 学生预约教师;教师接受;双方看到同一课堂和状态。 +4. 学生提交作业;教师评分;学生看到分数和反馈。 +5. 两个浏览器加入同一课堂,聊天、在线列表和举手状态双向同步;断线后恢复历史。 +6. 数据中心数字来自数据库;重置 seed 后结果可重复。 +7. `pnpm check` 覆盖 lint、format、构建和 API 测试;Playwright 覆盖至少三条跨角色 E2E。 diff --git a/docs/plans/2026-07-19-full-stack-completion.md b/docs/plans/2026-07-19-full-stack-completion.md new file mode 100644 index 0000000..665ee44 --- /dev/null +++ b/docs/plans/2026-07-19-full-stack-completion.md @@ -0,0 +1,139 @@ +# Full-stack Completion Implementation Plan + +> **For Codex:** Execute this plan in vertical slices. Each slice must finish with focused tests and an intentional commit; do not claim a capability before its acceptance test passes. + +**Goal:** Convert the current Vue prototype into a self-contained, portfolio-grade platform with persistent three-role workflows and reproducible evidence. + +**Architecture:** Keep the Vue application at the repository root. Add a Fastify ESM server under `server/`, backed by SQLite. Use HttpOnly opaque sessions, versioned REST endpoints, a unified classroom WebSocket, deterministic local providers, and production same-origin serving. + +**Tech Stack:** Vue 3, Vite, Pinia, Fastify 5, better-sqlite3, Zod/JSON Schema, Node crypto, `node:test`, Playwright. + +--- + +## Task 1: Establish the server foundation and executable contract + +**Files:** + +- Modify: `package.json`, `pnpm-lock.yaml`, `vite.config.js`, `.env.example`, `.gitignore` +- Create: `server/config.js`, `server/app.js`, `server/index.js`, `server/lib/http.js` +- Create: `server/test/health.test.js` + +**Work:** Add API/dev/build/test scripts, Vite proxy rules, health/readiness endpoints, structured errors, security headers, and production static serving. Write a failing health test first, then implement until it passes. + +**Verify:** `pnpm test:api --test-name-pattern health`, `pnpm build`. + +**Commit:** `feat(server): add self-contained API foundation` + +## Task 2: Add SQLite migrations, deterministic seed, and reset tooling + +**Files:** + +- Create: `server/db/schema.sql`, `server/db/database.js`, `server/db/migrate.js`, `server/db/seed.js`, `server/db/reset.js` +- Create: `server/test/database.test.js` +- Modify: `.gitignore`, `package.json` + +**Work:** Create users, sessions, codes, courses/reviews, appointments/classrooms, assignments/submissions, messages, notifications and audit tables with constraints/indexes. Seed three accounts and repeatable course/teacher/student data. Test migrations, foreign keys, idempotent seed and reset. + +**Verify:** `pnpm db:reset`, `pnpm test:api --test-name-pattern database`. + +**Commit:** `feat(database): add schema migrations and demo seed` + +## Task 3: Implement secure auth, profile, and authorization + +**Files:** + +- Create: `server/lib/password.js`, `server/lib/session.js`, `server/plugins/auth.js` +- Create: `server/routes/auth.js`, `server/routes/profile.js` +- Create: `server/test/auth.test.js` +- Modify: `src/utils/request.js`, `src/stores/modules/user.js`, `src/router/index.js`, `src/views/login/loginPage.vue`, `src/views/login/components/registerComponent.vue` + +**Work:** Implement scrypt password hashes, opaque cookie sessions, role/ownership guards, login rate limits, student/teacher registration, session restore/logout/profile/password endpoints. Update Axios and route guards to use cookie session without localStorage token. + +**Verify:** auth integration tests cover invalid input, wrong role, expiry, logout, 401 and 403; manually refresh an authenticated page. + +**Commit:** `feat(auth): add secure multi-role sessions` + +## Task 4: Complete course creation, review, and student discovery + +**Files:** + +- Create: `server/routes/courses.js`, `server/routes/admin.js`, `server/test/course-workflow.test.js` +- Create: `src/api/platform.js` +- Modify: teacher upload/list/detail views, administrator audit/data views, student online course views + +**Work:** Implement course CRUD and explicit status transitions. Replace static cards with loading/empty/error/pagination states. Wire teacher submit, administrator reject/approve with reason, and student published-only discovery. + +**Verify:** integration test exercises draft → rejected → revised → pending → published; Playwright confirms three-role visibility. + +**Commit:** `feat(courses): complete cross-role publishing workflow` + +## Task 5: Complete booking and classroom scheduling + +**Files:** + +- Create: `server/routes/teachers.js`, `server/routes/appointments.js`, `server/test/appointment-workflow.test.js` +- Modify: student teacher/search/detail/publish/home views; teacher docking/home views; administrator docking view + +**Work:** Replace local-only appointments with transactional APIs, prevent overlapping accepted slots, create a classroom on acceptance, and surface the same status to both roles. + +**Verify:** tests cover accept/reject/cancel, ownership, slot conflict and classroom creation; E2E checks student request → teacher accept. + +**Commit:** `feat(booking): connect student and teacher schedules` + +## Task 6: Complete assignments and grading + +**Files:** + +- Create: `server/routes/assignments.js`, `server/test/assignment-workflow.test.js` +- Modify: student homework view; teacher course detail/docking views + +**Work:** Drive questions from the selected course, persist drafts/submissions, prevent invalid duplicate submissions, add teacher grading and student result display. + +**Verify:** API test covers submit → grade → result plus cross-user access denial; E2E covers the role handoff. + +**Commit:** `feat(homework): add submission and grading loop` + +## Task 7: Make the classroom realtime path usable + +**Files:** + +- Create: `server/realtime/classroom.js`, `server/routes/classrooms.js`, `server/test/classroom.test.js` +- Modify: `src/config/runtime.js`, WebSocket/WebRTC composables, live classroom components + +**Work:** Issue short-lived join tickets, implement presence/chat/hand raise/history and RTC signal relay, connect UI initialization, fix stream binding and permission/error states. Text classroom must work without camera or TURN. + +**Verify:** integration test uses two WebSocket clients; Playwright checks two contexts exchange messages and restore history. + +**Commit:** `feat(classroom): add authenticated realtime rooms` + +## Task 8: Finish profiles, notifications, uploads, and local dialogue + +**Files:** + +- Create: `server/routes/files.js`, `server/routes/notifications.js`, `server/routes/dialogue.js`, provider modules and tests +- Modify: personal-center, messages, digital-human and dialogue views + +**Work:** Persist profile/password updates, validate local uploads, derive notifications from workflows, and replace alert-only dialogue with a deterministic provider plus Web Speech API TTS when available. + +**Verify:** upload magic-byte/size tests, notification ownership tests, deterministic dialogue snapshots, UI fallback checks. + +**Commit:** `feat(platform): complete supporting user workflows` + +## Task 9: Add end-to-end evidence, documentation, and CI + +**Files:** + +- Create: `playwright.config.js`, `e2e/*.spec.js`, `docs/api.md`, `docs/demo-script.md`, `SECURITY.md` +- Modify: `.github/workflows/ci.yml`, `README.md`, `package.json` + +**Work:** Add API tests to `pnpm check`, Playwright browser setup and three-role workflow specs, demo accounts, reset/start instructions, truthful capability matrix, architecture diagram and production limitations. + +**Verify:** clean install; reset; full `pnpm check`; Playwright; production build/start; dependency audit; final security review. + +**Commit:** `test(docs): prove and document full-stack completeness` + +## Task 10: Publish and merge + +**Work:** Review diff and secrets, push `agent/full-stack-completion`, open PR, wait for CI, address failures, merge to `main`, delete the branch, and fast-forward the local checkout to merged `main`. + +**Verify:** `git status --short --branch` is clean on `main`; remote default branch contains the merge; README badges/checks point at the canonical repository. diff --git a/e2e/requirements.txt b/e2e/requirements.txt new file mode 100644 index 0000000..484b16f --- /dev/null +++ b/e2e/requirements.txt @@ -0,0 +1 @@ +playwright==1.61.0 diff --git a/e2e/test_workflows.py b/e2e/test_workflows.py new file mode 100644 index 0000000..46ae37f --- /dev/null +++ b/e2e/test_workflows.py @@ -0,0 +1,748 @@ +"""Cross-role browser smoke test for the complete platform workflow. + +The suite deliberately establishes every authenticated session through the +real login page. It then uses the request context attached to each browser +context for verbose setup transitions while keeping the important user-facing +checkpoints in the UI. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +import time +import traceback +import urllib.error +import urllib.request +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +from playwright.sync_api import ( + APIRequestContext, + Browser, + BrowserContext, + Page, + Playwright, + expect, + sync_playwright, +) + + +BASE_URL = os.getenv("E2E_BASE_URL", "http://localhost:7777").rstrip("/") +API_URL = f"{BASE_URL}/api/v1" +ARTIFACT_DIR = Path("test-results/e2e") +DEMO_PASSWORD = "Demo123!" +ACCOUNTS = { + "student": ("student@example.com", "我是学生", "/student/home"), + "teacher": ("teacher@example.com", "我是老师", "/teacher/home"), + "administrator": ( + "admin@example.com", + "我是管理员", + "/administrator/courseDocking", + ), +} + + +def step(message: str) -> None: + print(f"[e2e] {message}", flush=True) + + +def wait_for_health(timeout_seconds: int = 90) -> None: + """Poll the production health endpoint before opening a browser.""" + + deadline = time.monotonic() + timeout_seconds + health_url = f"{API_URL}/health" + last_error: BaseException | None = None + + while time.monotonic() < deadline: + try: + request = urllib.request.Request( + health_url, + headers={"Accept": "application/json"}, + ) + with urllib.request.urlopen(request, timeout=2) as response: + payload = json.loads(response.read().decode("utf-8")) + if response.status == 200 and payload.get("data", {}).get( + "status" + ) == "ok": + step(f"service ready at {health_url}") + return + except ( + OSError, + TimeoutError, + urllib.error.URLError, + json.JSONDecodeError, + ) as error: + last_error = error + + time.sleep(0.5) + + raise RuntimeError( + f"service did not become healthy within {timeout_seconds}s: {last_error}" + ) + + +def api_call( + request: APIRequestContext, + method: str, + path: str, + *, + data: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, +) -> Any: + """Call an authenticated API using the cookie jar from a UI context.""" + + options: dict[str, Any] = { + "method": method, + "headers": { + "Accept": "application/json", + "Origin": BASE_URL, + }, + "fail_on_status_code": False, + } + if data is not None: + options["data"] = data + if params is not None: + options["params"] = params + + response = request.fetch(f"{API_URL}{path}", **options) + raw_body = response.text() + try: + body = json.loads(raw_body) + except json.JSONDecodeError as error: + raise AssertionError( + f"{method} {path} returned non-JSON HTTP {response.status}: " + f"{raw_body[:500]}" + ) from error + + if not response.ok or body.get("code") != 0: + raise AssertionError( + f"{method} {path} failed with HTTP {response.status}: " + f"{json.dumps(body, ensure_ascii=False)}" + ) + + return body.get("data") + + +def wait_for_network(page: Page) -> None: + try: + page.wait_for_load_state("networkidle", timeout=15_000) + except Exception: + # A WebSocket or a very slow decorative asset must not mask the + # concrete locator assertions that follow each navigation. + page.wait_for_load_state("domcontentloaded", timeout=15_000) + + +def open_page(page: Page, path: str) -> None: + page.goto(f"{BASE_URL}{path}", wait_until="domcontentloaded") + wait_for_network(page) + + +def login_through_ui(page: Page, role: str) -> dict[str, Any]: + email, role_label, expected_path = ACCOUNTS[role] + step(f"logging in {role} through the UI") + open_page(page, "/login") + + page.locator("main").get_by_text(role_label, exact=True).click() + page.locator("#email").fill(email) + page.locator("#password").fill(DEMO_PASSWORD) + page.locator("main input[type='checkbox']").check() + page.locator("main").get_by_role( + "button", name="登录", exact=True + ).click() + page.wait_for_url(re.compile(rf"{re.escape(BASE_URL)}{expected_path}(?:\?.*)?$")) + wait_for_network(page) + + profile = api_call(page.context.request, "GET", "/auth/session") + assert profile["role"] == role, ( + f"UI login established the wrong role: expected {role}, " + f"received {profile.get('role')}" + ) + assert profile["email"] == email + return profile + + +class WorkflowSuite: + def __init__(self, playwright: Playwright) -> None: + launch_options: dict[str, Any] = {"headless": True} + executable_path = os.getenv("E2E_BROWSER_EXECUTABLE", "").strip() + if executable_path: + launch_options["executable_path"] = executable_path + + self.browser: Browser = playwright.chromium.launch(**launch_options) + self.contexts: dict[str, BrowserContext] = {} + self.pages: dict[str, Page] = {} + self.console_messages: list[str] = [] + self.run_id = uuid.uuid4().hex[:10] + + for role in ACCOUNTS: + context = self.browser.new_context( + viewport={"width": 1440, "height": 1000}, + locale="zh-CN", + timezone_id="Asia/Shanghai", + ) + context.tracing.start(screenshots=True, snapshots=True, sources=True) + page = context.new_page() + page.set_default_timeout(20_000) + page.on( + "console", + lambda message, current_role=role: self.console_messages.append( + f"[{current_role}] console.{message.type}: {message.text}" + ), + ) + page.on( + "pageerror", + lambda error, current_role=role: self.console_messages.append( + f"[{current_role}] pageerror: {error}" + ), + ) + self.contexts[role] = context + self.pages[role] = page + + def close(self, *, failed: bool) -> None: + ARTIFACT_DIR.mkdir(parents=True, exist_ok=True) + if failed: + (ARTIFACT_DIR / "browser.log").write_text( + "\n".join(self.console_messages), encoding="utf-8" + ) + + for role, context in self.contexts.items(): + page = self.pages[role] + if failed: + try: + page.screenshot( + path=str(ARTIFACT_DIR / f"failure-{role}.png"), + full_page=True, + ) + except Exception as error: + self.console_messages.append( + f"[{role}] unable to capture screenshot: {error}" + ) + + try: + if failed: + context.tracing.stop( + path=str(ARTIFACT_DIR / f"trace-{role}.zip") + ) + else: + context.tracing.stop() + except Exception as error: + if failed: + self.console_messages.append( + f"[{role}] unable to save trace: {error}" + ) + context.close() + + if failed: + (ARTIFACT_DIR / "browser.log").write_text( + "\n".join(self.console_messages), encoding="utf-8" + ) + self.browser.close() + + def establish_sessions(self) -> dict[str, dict[str, Any]]: + return { + role: login_through_ui(self.pages[role], role) for role in ACCOUNTS + } + + def course_review_workflow(self) -> str: + teacher = self.contexts["teacher"].request + administrator = self.contexts["administrator"].request + student = self.contexts["student"].request + teacher_page = self.pages["teacher"] + administrator_page = self.pages["administrator"] + student_page = self.pages["student"] + + initial_title = f"E2E 跨文化会话 {self.run_id}" + revised_title = f"{initial_title} 修订版" + rejection_note = f"请补充跨文化任务说明 {self.run_id}" + + step("course: teacher creates a draft and submits it") + course = api_call( + teacher, + "POST", + "/courses", + data={ + "title": initial_title, + "summary": "用真实场景练习问候、追问与观点表达。", + "description": "包含课前输入、课堂任务、同伴练习与课后复盘。", + "level": "intermediate", + "category": "文化口语", + "coverUrl": None, + "durationMinutes": 45, + "priceCents": 8800, + "capacity": 12, + }, + ) + course_id = course["id"] + submitted = api_call(teacher, "POST", f"/courses/{course_id}/submit") + assert submitted["status"] == "pending" + + open_page(teacher_page, "/teacher/onlineCourses") + teacher_card = teacher_page.locator("article.course-card").filter( + has_text=initial_title + ) + expect(teacher_card).to_be_visible() + expect(teacher_card.get_by_text("待审核", exact=True)).to_be_visible() + + step("course: administrator sees and rejects the pending course") + open_page(administrator_page, "/administrator/auditCenter") + expect( + administrator_page.get_by_text(initial_title, exact=True).first + ).to_be_visible() + rejected = api_call( + administrator, + "POST", + f"/admin/course-reviews/{course_id}", + data={"action": "reject", "note": rejection_note}, + ) + assert rejected["course"]["status"] == "rejected" + + open_page(administrator_page, "/administrator/auditCenter") + administrator_page.get_by_role( + "button", name="已驳回", exact=True + ).click() + expect( + administrator_page.get_by_text(initial_title, exact=True).first + ).to_be_visible() + + hidden = api_call( + student, + "GET", + "/courses", + params={"search": initial_title, "page": 1, "pageSize": 20}, + ) + assert hidden["items"] == [], "a rejected course leaked into student catalog" + + step("course: teacher revises, resubmits, and administrator approves") + updated = api_call( + teacher, + "PATCH", + f"/courses/{course_id}", + data={ + "title": revised_title, + "summary": "新增跨文化比较任务,并明确每个环节的学习产出。", + "description": ( + "课前阅读场景材料;课堂完成角色扮演和追问;" + "课后提交表达复盘。" + ), + }, + ) + assert updated["status"] == "rejected" + resubmitted = api_call( + teacher, "POST", f"/courses/{course_id}/submit" + ) + assert resubmitted["status"] == "pending" + + open_page(teacher_page, "/teacher/onlineCourses") + revised_card = teacher_page.locator("article.course-card").filter( + has_text=revised_title + ) + expect(revised_card).to_be_visible() + expect(revised_card.get_by_text("待审核", exact=True)).to_be_visible() + + approved = api_call( + administrator, + "POST", + f"/admin/course-reviews/{course_id}", + data={ + "action": "approve", + "note": "学习目标、活动与产出已经对齐,同意发布。", + }, + ) + assert approved["course"]["status"] == "published" + + step("course: student finds the approved course in the real catalog UI") + open_page(student_page, "/student/course") + search = student_page.get_by_placeholder("搜索课程、简介或教师") + search.fill(revised_title) + search.press("Enter") + expect(student_page.get_by_text(revised_title, exact=True).first).to_be_visible() + + return course_id + + def appointment_classroom_workflow( + self, *, course_id: str, teacher_id: str + ) -> None: + student = self.contexts["student"].request + teacher = self.contexts["teacher"].request + student_page = self.pages["student"] + teacher_page = self.pages["teacher"] + topic = f"E2E 即时口语课堂 {self.run_id}" + scheduled_start = datetime.now(timezone.utc) + timedelta(minutes=8) + + step("appointment: student requests a near-term lesson") + appointment = api_call( + student, + "POST", + "/appointments", + data={ + "teacherId": teacher_id, + "courseId": course_id, + "topic": topic, + "message": "希望练习跨文化寒暄和自然追问。", + "scheduledStart": scheduled_start.isoformat(), + "durationMinutes": 15, + }, + ) + appointment_id = appointment["id"] + assert appointment["status"] == "pending" + + open_page(teacher_page, "/teacher/teachingDocking") + pending_card = teacher_page.locator("article.appointment-card").filter( + has_text=topic + ) + expect(pending_card).to_be_visible() + expect(pending_card.get_by_text("等待处理", exact=True)).to_be_visible() + + step("appointment: teacher accepts and creates the dedicated classroom") + accepted = api_call( + teacher, + "PATCH", + f"/appointments/{appointment_id}/respond", + data={"action": "accept", "note": "课堂材料已经准备好。"}, + ) + assert accepted["status"] == "accepted" + assert accepted["classroom"]["id"] + + step("appointment: student sees the booking and enters through the UI") + open_page(student_page, "/student/home") + student_card = student_page.locator(".appointment-list article").filter( + has_text=topic + ) + expect(student_card).to_be_visible() + expect(student_card.get_by_text("已确认", exact=True)).to_be_visible() + student_card.get_by_role("button", name="进入课堂", exact=True).click() + student_page.wait_for_url(re.compile(r"/student/liveClass\?classroomId=")) + expect( + student_page.get_by_role("heading", name="实时互动课堂", exact=True) + ).to_be_visible() + expect(student_page.get_by_text("课堂消息", exact=True)).to_be_visible() + + step("appointment: student confirms classroom completion in the UI") + student_page.get_by_role( + "button", name="结束课堂", exact=True + ).click() + confirmation = student_page.locator(".el-message-box") + expect(confirmation).to_be_visible() + confirmation.get_by_role( + "button", name="结束课堂", exact=True + ).click() + student_page.wait_for_url(re.compile(r"/student/home(?:\?.*)?$")) + + completed = api_call( + student, + "GET", + "/appointments", + params={"status": "completed", "page": 1, "pageSize": 100}, + ) + matching = [item for item in completed["items"] if item["id"] == appointment_id] + assert len(matching) == 1 and matching[0]["status"] == "completed" + + def assignment_workflow(self, *, course_id: str) -> None: + teacher = self.contexts["teacher"].request + student = self.contexts["student"].request + teacher_page = self.pages["teacher"] + student_page = self.pages["student"] + assignment_title = f"E2E 表达复盘 {self.run_id}" + prompt = f"请写一句包含“因为…所以…”的课堂复盘({self.run_id})。" + feedback = f"结构完整,连接词使用自然。E2E-{self.run_id}" + + step("assignment: teacher creates and publishes a real assignment") + assignment = api_call( + teacher, + "POST", + f"/courses/{course_id}/assignments", + data={ + "title": assignment_title, + "instructions": "结合刚才的口语课堂完成一段简短复盘。", + "dueAt": ( + datetime.now(timezone.utc) + timedelta(days=2) + ).isoformat(), + "maxScore": 100, + "questions": [ + { + "type": "text", + "prompt": prompt, + "correctAnswer": "因为我认真练习,所以表达更自然了。", + "points": 100, + "explanation": "关注关联词是否完整、语序是否自然。", + } + ], + }, + ) + assignment_id = assignment["id"] + question_id = assignment["questions"][0]["id"] + published = api_call( + teacher, "POST", f"/assignments/{assignment_id}/publish" + ) + assert published["status"] == "published" + + open_page( + teacher_page, + f"/teacher/courseDetails?id={course_id}&assignmentId={assignment_id}", + ) + expect( + teacher_page.get_by_text(assignment_title, exact=True).first + ).to_be_visible() + + step("assignment: student sees it in the workbook and submits") + open_page( + student_page, + f"/student/homeWork?courseId={course_id}&assignmentId={assignment_id}", + ) + expect( + student_page.get_by_text(assignment_title, exact=True).first + ).to_be_visible() + submission = api_call( + student, + "POST", + f"/assignments/{assignment_id}/submissions", + data={ + "answers": { + question_id: "因为今天反复练习,所以我的表达更自然了。" + }, + "action": "submit", + }, + ) + assert submission["status"] == "submitted" + + step("assignment: teacher grades, then student sees score and feedback") + graded = api_call( + teacher, + "PATCH", + f"/submissions/{submission['id']}/grade", + data={"score": 93, "feedback": feedback}, + ) + assert graded["status"] == "graded" + assert graded["score"] == 93 + + open_page( + student_page, + f"/student/homeWork?submissionId={submission['id']}", + ) + feedback_card = student_page.locator(".feedback-card") + expect(feedback_card).to_be_visible() + expect(feedback_card.get_by_text("93", exact=True)).to_be_visible() + expect(feedback_card.get_by_text(feedback, exact=True)).to_be_visible() + + def teacher_verification_workflow( + self, + *, + teacher_id: str, + teacher_email: str, + teacher_display_name: str, + ) -> None: + administrator = self.contexts["administrator"].request + student = self.contexts["student"].request + administrator_page = self.pages["administrator"] + student_page = self.pages["student"] + revocation_reason = f"E2E 定期复核教师材料 {self.run_id}" + approval_note = f"E2E 已完成人工复核 {self.run_id}" + + def restore_verified_state() -> None: + """Best-effort cleanup when an assertion interrupts the UI flow.""" + + try: + pending = api_call( + administrator, + "GET", + "/admin/teacher-verifications", + params={ + "status": "pending", + "search": teacher_email, + "page": 1, + "pageSize": 10, + }, + ) + if any(item["id"] == teacher_id for item in pending["items"]): + api_call( + administrator, + "POST", + f"/admin/teacher-verifications/{teacher_id}", + data={ + "action": "approve", + "note": f"E2E 失败清理:恢复种子教师认证 {self.run_id}", + }, + ) + step("teacher verification: cleanup restored verified state") + except BaseException as error: + step( + "teacher verification: unable to restore verified state: " + f"{error}" + ) + + try: + step("teacher verification: administrator opens the verified queue") + open_page(administrator_page, "/administrator/auditCenter") + administrator_page.get_by_role( + "button", name="教师身份认证", exact=True + ).click() + verification_panel = administrator_page.locator( + "section.teacher-verification" + ) + expect(verification_panel).to_be_visible() + verification_panel.get_by_role( + "button", name="已认证", exact=True + ).click() + admin_search = verification_panel.get_by_placeholder( + "搜索教师、邮箱或任职机构" + ) + admin_search.fill(teacher_email) + admin_search.press("Enter") + + verified_row = verification_panel.locator( + "tr.el-table__row" + ).filter(has_text=teacher_email).first + expect(verified_row).to_be_visible() + expect( + verified_row.get_by_text(teacher_display_name, exact=True) + ).to_be_visible() + + step("teacher verification: administrator revokes through the UI") + verified_row.get_by_role( + "button", name="撤销认证", exact=True + ).click() + revoke_dialog = administrator_page.locator(".el-dialog:visible") + expect(revoke_dialog).to_be_visible() + revoke_dialog.locator("textarea").fill(revocation_reason) + revoke_dialog.get_by_role( + "button", name="确认撤销", exact=True + ).click() + expect(revoke_dialog).to_be_hidden() + expect(verified_row).to_have_count(0) + + step( + "teacher verification: student cannot discover, open, or book " + "the revoked teacher" + ) + open_page(student_page, "/student/order") + student_search = student_page.get_by_placeholder( + "搜索教师、学校或教学方向" + ) + student_search.fill(teacher_display_name) + student_search.press("Enter") + hidden_card = student_page.locator("article.teacher-card").filter( + has_text=teacher_display_name + ) + expect(hidden_card).to_have_count(0) + expect( + student_page.get_by_text( + "没有找到符合条件的教师", exact=True + ) + ).to_be_visible() + + open_page( + student_page, + f"/student/teacherDetail?teacherId={teacher_id}", + ) + expect( + student_page.get_by_text("教师资料无法加载", exact=True) + ).to_be_visible() + + booking_response = student.fetch( + f"{API_URL}/appointments", + method="POST", + headers={ + "Accept": "application/json", + "Origin": BASE_URL, + }, + data={ + "teacherId": teacher_id, + "courseId": None, + "topic": f"E2E 认证门禁预约 {self.run_id}", + "message": "该请求应被教师认证门禁拒绝。", + "scheduledStart": ( + datetime.now(timezone.utc) + timedelta(days=30) + ).isoformat(), + "durationMinutes": 15, + }, + fail_on_status_code=False, + ) + booking_body = json.loads(booking_response.text()) + assert booking_response.status == 409, ( + "a revoked teacher unexpectedly accepted an appointment: " + f"HTTP {booking_response.status} {booking_response.text()}" + ) + assert booking_body.get("msg") == "该教师尚未通过平台认证,暂不可预约" + + step("teacher verification: administrator reapproves through the UI") + verification_panel.get_by_role( + "button", name="待认证", exact=True + ).click() + pending_row = verification_panel.locator( + "tr.el-table__row" + ).filter(has_text=teacher_email).first + expect(pending_row).to_be_visible() + pending_row.get_by_role( + "button", name="通过认证", exact=True + ).click() + approve_dialog = administrator_page.locator(".el-dialog:visible") + expect(approve_dialog).to_be_visible() + approve_dialog.locator("textarea").fill(approval_note) + approve_dialog.get_by_role( + "button", name="确认通过", exact=True + ).click() + expect(approve_dialog).to_be_hidden() + expect(pending_row).to_have_count(0) + + step("teacher verification: student discovers the teacher again") + open_page(student_page, "/student/order") + student_search = student_page.get_by_placeholder( + "搜索教师、学校或教学方向" + ) + student_search.fill(teacher_display_name) + student_search.press("Enter") + restored_card = student_page.locator("article.teacher-card").filter( + has_text=teacher_display_name + ) + expect(restored_card).to_be_visible() + expect( + restored_card.get_by_text("已认证", exact=True) + ).to_be_visible() + finally: + restore_verified_state() + + def run(self) -> None: + profiles = self.establish_sessions() + course_id = self.course_review_workflow() + self.appointment_classroom_workflow( + course_id=course_id, + teacher_id=profiles["teacher"]["id"], + ) + self.assignment_workflow(course_id=course_id) + self.teacher_verification_workflow( + teacher_id=profiles["teacher"]["id"], + teacher_email=profiles["teacher"]["email"], + teacher_display_name=profiles["teacher"]["displayName"], + ) + + +def main() -> int: + ARTIFACT_DIR.mkdir(parents=True, exist_ok=True) + wait_for_health() + suite: WorkflowSuite | None = None + failed = False + + try: + with sync_playwright() as playwright: + suite = WorkflowSuite(playwright) + try: + suite.run() + step("all four cross-role workflows passed") + except BaseException: + failed = True + raise + finally: + suite.close(failed=failed) + return 0 + except BaseException: + traceback.print_exc() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/package.json b/package.json index cd0fe07..f4e7844 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "international-chinese-platform", - "version": "0.0.0", + "version": "1.0.0", "private": true, - "description": "A Vue 3 prototype for an international Chinese-language education platform.", - "packageManager": "pnpm@8.15.9", + "description": "A self-contained full-stack international Chinese-language education platform.", + "packageManager": "pnpm@11.9.0", "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" }, "repository": { "type": "git", @@ -14,30 +14,51 @@ "homepage": "https://github.com/computersciencefreshmen/International_Chinese_Platform#readme", "type": "module", "scripts": { - "dev": "vite", + "dev": "concurrently --kill-others-on-fail --names web,api --prefix-colors cyan,magenta \"pnpm dev:web\" \"pnpm dev:api\"", + "dev:web": "vite", + "dev:api": "node --env-file-if-exists=.env --env-file-if-exists=.env.local --watch server/index.js", + "start": "node --env-file-if-exists=.env --env-file-if-exists=.env.local server/index.js", "build": "vite build", "preview": "vite preview", + "db:migrate": "node --env-file-if-exists=.env --env-file-if-exists=.env.local server/db/migrate.js", + "db:seed": "node --env-file-if-exists=.env --env-file-if-exists=.env.local server/db/seed.js", + "db:reset": "node --env-file-if-exists=.env --env-file-if-exists=.env.local server/db/reset.js", + "admin:bootstrap": "node --env-file-if-exists=.env --env-file-if-exists=.env.local server/db/bootstrap-admin.js", + "test": "pnpm test:api", + "test:api": "node --test \"server/test/*.test.js\"", "lint": "eslint . --fix", "lint:check": "eslint .", - "format": "prettier --write src/", - "format:check": "prettier --check src/", - "check": "pnpm lint:check && pnpm format:check && pnpm build" + "format": "prettier --write src/ server/ docs/ README.md README.en.md .github/", + "format:check": "prettier --check src/ server/ docs/ README.md README.en.md .github/", + "check": "pnpm lint:check && pnpm format:check && pnpm test:api && pnpm build" }, "dependencies": { "@element-plus/icons-vue": "^2.3.1", + "@fastify/cookie": "^11.1.2", + "@fastify/helmet": "^13.1.0", + "@fastify/multipart": "^10.1.0", + "@fastify/rate-limit": "^11.1.0", + "@fastify/static": "^10.1.0", + "@fastify/websocket": "^11.3.0", "axios": "^1.8.4", + "better-sqlite3": "^12.11.1", "element-plus": "^2.9.5", + "fastify": "^5.10.0", + "nodemailer": "^9.0.3", "pinia": "^2.3.1", - "pinia-plugin-persistedstate": "^4.2.0", + "pinia-plugin-persistedstate": "4.2.0", "vue": "^3.5.13", - "vue-i18n": "11.0.0-rc.1", - "vue-router": "^4.5.0" + "vue-i18n": "^11.4.6", + "vue-router": "^4.5.0", + "ws": "^8.21.1", + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^9.18.0", "@vitejs/plugin-vue": "^5.2.1", "@vue/eslint-config-prettier": "^10.1.0", "autoprefixer": "^10.4.20", + "concurrently": "^10.0.3", "eslint": "^9.20.1", "eslint-plugin-prettier": "^5.2.3", "eslint-plugin-vue": "^9.32.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87efecf..cad8a71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,2027 +1,1394 @@ -lockfileVersion: '6.0' +lockfileVersion: '9.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false -dependencies: - '@element-plus/icons-vue': - specifier: ^2.3.1 - version: 2.3.1(vue@3.5.13) - axios: - specifier: ^1.8.4 - version: 1.9.0 - element-plus: - specifier: ^2.9.5 - version: 2.9.10(vue@3.5.13) - pinia: - specifier: ^2.3.1 - version: 2.3.1(vue@3.5.13) - pinia-plugin-persistedstate: - specifier: ^4.2.0 - version: 4.3.0(pinia@2.3.1) - vue: - specifier: ^3.5.13 - version: 3.5.13 - vue-i18n: - specifier: 11.0.0-rc.1 - version: 11.0.0-rc.1(vue@3.5.13) - vue-router: - specifier: ^4.5.0 - version: 4.5.1(vue@3.5.13) - -devDependencies: - '@eslint/js': - specifier: ^9.18.0 - version: 9.26.0 - '@vitejs/plugin-vue': - specifier: ^5.2.1 - version: 5.2.4(vite@6.3.5)(vue@3.5.13) - '@vue/eslint-config-prettier': - specifier: ^10.1.0 - version: 10.2.0(eslint@9.26.0)(prettier@3.5.3) - autoprefixer: - specifier: ^10.4.20 - version: 10.4.21(postcss@8.5.3) - eslint: - specifier: ^9.20.1 - version: 9.26.0 - eslint-plugin-prettier: - specifier: ^5.2.3 - version: 5.4.0(eslint-config-prettier@10.1.5)(eslint@9.26.0)(prettier@3.5.3) - eslint-plugin-vue: - specifier: ^9.32.0 - version: 9.33.0(eslint@9.26.0) - postcss: - specifier: ^8.5.2 - version: 8.5.3 - prettier: - specifier: ^3.5.1 - version: 3.5.3 - sass: - specifier: ^1.85.0 - version: 1.88.0 - tailwindcss: - specifier: ~3.0.24 - version: 3.0.24(postcss@8.5.3) - vite: - specifier: ^6.0.11 - version: 6.3.5(sass@1.88.0)(yaml@2.7.1) - vite-plugin-vue-devtools: - specifier: ^7.7.1 - version: 7.7.6(vite@6.3.5)(vue@3.5.13) - yaml: - specifier: ^2.4.2 - version: 2.7.1 +overrides: + ini@<1.3.6: 1.3.8 + +importers: + + .: + dependencies: + '@element-plus/icons-vue': + specifier: ^2.3.1 + version: 2.3.2(vue@3.5.40) + '@fastify/cookie': + specifier: ^11.1.2 + version: 11.1.2 + '@fastify/helmet': + specifier: ^13.1.0 + version: 13.1.0 + '@fastify/multipart': + specifier: ^10.1.0 + version: 10.1.0 + '@fastify/rate-limit': + specifier: ^11.1.0 + version: 11.1.0 + '@fastify/static': + specifier: ^10.1.0 + version: 10.1.0 + '@fastify/websocket': + specifier: ^11.3.0 + version: 11.3.0 + axios: + specifier: ^1.8.4 + version: 1.18.1 + better-sqlite3: + specifier: ^12.11.1 + version: 12.11.1 + element-plus: + specifier: ^2.9.5 + version: 2.14.3(vue@3.5.40) + fastify: + specifier: ^5.10.0 + version: 5.10.0 + nodemailer: + specifier: ^9.0.3 + version: 9.0.3 + pinia: + specifier: ^2.3.1 + version: 2.3.1(vue@3.5.40) + pinia-plugin-persistedstate: + specifier: 4.2.0 + version: 4.2.0(pinia@2.3.1(vue@3.5.40)) + vue: + specifier: ^3.5.13 + version: 3.5.40 + vue-i18n: + specifier: ^11.4.6 + version: 11.4.6(vue@3.5.40) + vue-router: + specifier: ^4.5.0 + version: 4.6.4(vue@3.5.40) + ws: + specifier: ^8.21.1 + version: 8.21.1 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@eslint/js': + specifier: ^9.18.0 + version: 9.39.5 + '@vitejs/plugin-vue': + specifier: ^5.2.1 + version: 5.2.1(vite@6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.40) + '@vue/eslint-config-prettier': + specifier: ^10.1.0 + version: 10.2.0(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.5) + autoprefixer: + specifier: ^10.4.20 + version: 10.5.4(postcss@8.5.19) + concurrently: + specifier: ^10.0.3 + version: 10.0.3 + eslint: + specifier: ^9.20.1 + version: 9.39.5(jiti@2.7.0) + eslint-plugin-prettier: + specifier: ^5.2.3 + version: 5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.5) + eslint-plugin-vue: + specifier: ^9.32.0 + version: 9.32.0(eslint@9.39.5(jiti@2.7.0)) + postcss: + specifier: ^8.5.2 + version: 8.5.19 + prettier: + specifier: ^3.5.1 + version: 3.9.5 + sass: + specifier: ^1.85.0 + version: 1.101.0 + tailwindcss: + specifier: ~3.0.24 + version: 3.0.24(postcss@8.5.19) + vite: + specifier: ^6.0.11 + version: 6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0) + vite-plugin-vue-devtools: + specifier: ^7.7.1 + version: 7.7.10(rollup@4.62.2)(vite@6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.40) + yaml: + specifier: ^2.4.2 + version: 2.9.0 packages: - /@ampproject/remapping@2.3.0: - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==, tarball: https://registry.npmmirror.com/@ampproject/remapping/-/remapping-2.3.0.tgz} - engines: {node: '>=6.0.0'} - dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 - dev: true + '@antfu/utils@0.7.10': + resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} - /@antfu/utils@0.7.10: - resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==, tarball: https://registry.npmmirror.com/@antfu/utils/-/utils-0.7.10.tgz} - dev: true - - /@babel/code-frame@7.27.1: - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==, tarball: https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.27.1.tgz} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-validator-identifier': 7.27.1 - js-tokens: 4.0.0 - picocolors: 1.1.1 - dev: true - /@babel/compat-data@7.27.2: - resolution: {integrity: sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==, tarball: https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.27.2.tgz} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} - dev: true - /@babel/core@7.27.1: - resolution: {integrity: sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==, tarball: https://registry.npmmirror.com/@babel/core/-/core-7.27.1.tgz} + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.27.1 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.27.1(@babel/core@7.27.1) - '@babel/helpers': 7.27.1 - '@babel/parser': 7.27.2 - '@babel/template': 7.27.2 - '@babel/traverse': 7.27.1 - '@babel/types': 7.27.1 - convert-source-map: 2.0.0 - debug: 4.4.0 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - dev: true - /@babel/generator@7.27.1: - resolution: {integrity: sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==, tarball: https://registry.npmmirror.com/@babel/generator/-/generator-7.27.1.tgz} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/parser': 7.27.2 - '@babel/types': 7.27.1 - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 - jsesc: 3.1.0 - dev: true - /@babel/helper-annotate-as-pure@7.27.1: - resolution: {integrity: sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==, tarball: https://registry.npmmirror.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz} + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.27.1 - dev: true - /@babel/helper-compilation-targets@7.27.2: - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==, tarball: https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz} + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/compat-data': 7.27.2 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.24.5 - lru-cache: 5.1.1 - semver: 6.3.1 - dev: true - /@babel/helper-create-class-features-plugin@7.27.1(@babel/core@7.27.1): - resolution: {integrity: sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==, tarball: https://registry.npmmirror.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz} + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-annotate-as-pure': 7.27.1 - '@babel/helper-member-expression-to-functions': 7.27.1 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.27.1(@babel/core@7.27.1) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.27.1 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - dev: true - /@babel/helper-member-expression-to-functions@7.27.1: - resolution: {integrity: sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==, tarball: https://registry.npmmirror.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/traverse': 7.27.1 - '@babel/types': 7.27.1 - transitivePeerDependencies: - - supports-color - dev: true - /@babel/helper-module-imports@7.27.1: - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==, tarball: https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz} + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/traverse': 7.27.1 - '@babel/types': 7.27.1 - transitivePeerDependencies: - - supports-color - dev: true - /@babel/helper-module-transforms@7.27.1(@babel/core@7.27.1): - resolution: {integrity: sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==, tarball: https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz} + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.27.1 - transitivePeerDependencies: - - supports-color - dev: true - /@babel/helper-optimise-call-expression@7.27.1: - resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==, tarball: https://registry.npmmirror.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz} + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.27.1 - dev: true - /@babel/helper-plugin-utils@7.27.1: - resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==, tarball: https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz} + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} engines: {node: '>=6.9.0'} - dev: true - /@babel/helper-replace-supers@7.27.1(@babel/core@7.27.1): - resolution: {integrity: sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==, tarball: https://registry.npmmirror.com/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz} + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-member-expression-to-functions': 7.27.1 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.27.1 - transitivePeerDependencies: - - supports-color - dev: true - /@babel/helper-skip-transparent-expression-wrappers@7.27.1: - resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==, tarball: https://registry.npmmirror.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz} + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/traverse': 7.27.1 - '@babel/types': 7.27.1 - transitivePeerDependencies: - - supports-color - dev: true - /@babel/helper-string-parser@7.27.1: - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==, tarball: https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - /@babel/helper-validator-identifier@7.27.1: - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==, tarball: https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - /@babel/helper-validator-option@7.27.1: - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==, tarball: https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} - dev: true - /@babel/helpers@7.27.1: - resolution: {integrity: sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==, tarball: https://registry.npmmirror.com/@babel/helpers/-/helpers-7.27.1.tgz} + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.27.1 - dev: true - /@babel/parser@7.27.2: - resolution: {integrity: sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==, tarball: https://registry.npmmirror.com/@babel/parser/-/parser-7.27.2.tgz} + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} hasBin: true - dependencies: - '@babel/types': 7.27.1 - /@babel/plugin-proposal-decorators@7.27.1(@babel/core@7.27.1): - resolution: {integrity: sha512-DTxe4LBPrtFdsWzgpmbBKevg3e9PBy+dXRt19kSbucbZvL2uqtdqwwpluL1jfxYE0wIDTFp1nTy/q6gNLsxXrg==, tarball: https://registry.npmmirror.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.27.1.tgz} + '@babel/plugin-proposal-decorators@7.29.7': + resolution: {integrity: sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-create-class-features-plugin': 7.27.1(@babel/core@7.27.1) - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-decorators': 7.27.1(@babel/core@7.27.1) - transitivePeerDependencies: - - supports-color - dev: true - /@babel/plugin-syntax-decorators@7.27.1(@babel/core@7.27.1): - resolution: {integrity: sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.27.1.tgz} + '@babel/plugin-syntax-decorators@7.29.7': + resolution: {integrity: sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - /@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.27.1): - resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz} + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - /@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.27.1): - resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz} + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} peerDependencies: '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - /@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.27.1): - resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz} + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - /@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.27.1): - resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz} + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - dev: true - /@babel/plugin-transform-typescript@7.27.1(@babel/core@7.27.1): - resolution: {integrity: sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.1.tgz} + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-annotate-as-pure': 7.27.1 - '@babel/helper-create-class-features-plugin': 7.27.1(@babel/core@7.27.1) - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.27.1) - transitivePeerDependencies: - - supports-color - dev: true - /@babel/template@7.27.2: - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==, tarball: https://registry.npmmirror.com/@babel/template/-/template-7.27.2.tgz} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.27.2 - '@babel/types': 7.27.1 - dev: true - /@babel/traverse@7.27.1: - resolution: {integrity: sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==, tarball: https://registry.npmmirror.com/@babel/traverse/-/traverse-7.27.1.tgz} + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.27.1 - '@babel/parser': 7.27.2 - '@babel/template': 7.27.2 - '@babel/types': 7.27.1 - debug: 4.4.0 - globals: 11.12.0 - transitivePeerDependencies: - - supports-color - dev: true - /@babel/types@7.27.1: - resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==, tarball: https://registry.npmmirror.com/@babel/types/-/types-7.27.1.tgz} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - /@ctrl/tinycolor@3.6.1: - resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==, tarball: https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz} - engines: {node: '>=10'} - dev: false + '@ctrl/tinycolor@4.2.0': + resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} + engines: {node: '>=14'} - /@element-plus/icons-vue@2.3.1(vue@3.5.13): - resolution: {integrity: sha512-XxVUZv48RZAd87ucGS48jPf6pKu0yV5UCg9f4FFwtrYxXOwWuVJo6wOvSLKEoMQKjv8GsX/mhP6UsC1lRwbUWg==, tarball: https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.1.tgz} + '@element-plus/icons-vue@2.3.2': + resolution: {integrity: sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==} peerDependencies: vue: ^3.2.0 - dependencies: - vue: 3.5.13 - dev: false - /@esbuild/aix-ppc64@0.25.4: - resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==, tarball: https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz} + '@esbuild/aix-ppc64@0.25.0': + resolution: {integrity: sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - requiresBuild: true - dev: true - optional: true - /@esbuild/android-arm64@0.25.4: - resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==, tarball: https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz} + '@esbuild/android-arm64@0.25.0': + resolution: {integrity: sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==} engines: {node: '>=18'} cpu: [arm64] os: [android] - requiresBuild: true - dev: true - optional: true - /@esbuild/android-arm@0.25.4: - resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==, tarball: https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.4.tgz} + '@esbuild/android-arm@0.25.0': + resolution: {integrity: sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==} engines: {node: '>=18'} cpu: [arm] os: [android] - requiresBuild: true - dev: true - optional: true - /@esbuild/android-x64@0.25.4: - resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==, tarball: https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.4.tgz} + '@esbuild/android-x64@0.25.0': + resolution: {integrity: sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==} engines: {node: '>=18'} cpu: [x64] os: [android] - requiresBuild: true - dev: true - optional: true - /@esbuild/darwin-arm64@0.25.4: - resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==, tarball: https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz} + '@esbuild/darwin-arm64@0.25.0': + resolution: {integrity: sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - requiresBuild: true - dev: true - optional: true - /@esbuild/darwin-x64@0.25.4: - resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==, tarball: https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz} + '@esbuild/darwin-x64@0.25.0': + resolution: {integrity: sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - requiresBuild: true - dev: true - optional: true - /@esbuild/freebsd-arm64@0.25.4: - resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==, tarball: https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz} + '@esbuild/freebsd-arm64@0.25.0': + resolution: {integrity: sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - requiresBuild: true - dev: true - optional: true - /@esbuild/freebsd-x64@0.25.4: - resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==, tarball: https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz} + '@esbuild/freebsd-x64@0.25.0': + resolution: {integrity: sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-arm64@0.25.4: - resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==, tarball: https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz} + '@esbuild/linux-arm64@0.25.0': + resolution: {integrity: sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-arm@0.25.4: - resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==, tarball: https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz} + '@esbuild/linux-arm@0.25.0': + resolution: {integrity: sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==} engines: {node: '>=18'} cpu: [arm] os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-ia32@0.25.4: - resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==, tarball: https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz} + '@esbuild/linux-ia32@0.25.0': + resolution: {integrity: sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-loong64@0.25.4: - resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==, tarball: https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz} + '@esbuild/linux-loong64@0.25.0': + resolution: {integrity: sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-mips64el@0.25.4: - resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==, tarball: https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz} + '@esbuild/linux-mips64el@0.25.0': + resolution: {integrity: sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-ppc64@0.25.4: - resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==, tarball: https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz} + '@esbuild/linux-ppc64@0.25.0': + resolution: {integrity: sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-riscv64@0.25.4: - resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==, tarball: https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz} + '@esbuild/linux-riscv64@0.25.0': + resolution: {integrity: sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-s390x@0.25.4: - resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==, tarball: https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz} + '@esbuild/linux-s390x@0.25.0': + resolution: {integrity: sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-x64@0.25.4: - resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==, tarball: https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz} + '@esbuild/linux-x64@0.25.0': + resolution: {integrity: sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==} engines: {node: '>=18'} cpu: [x64] os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/netbsd-arm64@0.25.4: - resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==, tarball: https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz} + '@esbuild/netbsd-arm64@0.25.0': + resolution: {integrity: sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - requiresBuild: true - dev: true - optional: true - /@esbuild/netbsd-x64@0.25.4: - resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==, tarball: https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz} + '@esbuild/netbsd-x64@0.25.0': + resolution: {integrity: sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - requiresBuild: true - dev: true - optional: true - /@esbuild/openbsd-arm64@0.25.4: - resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==, tarball: https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz} + '@esbuild/openbsd-arm64@0.25.0': + resolution: {integrity: sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - requiresBuild: true - dev: true - optional: true - /@esbuild/openbsd-x64@0.25.4: - resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==, tarball: https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz} + '@esbuild/openbsd-x64@0.25.0': + resolution: {integrity: sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - requiresBuild: true - dev: true - optional: true - /@esbuild/sunos-x64@0.25.4: - resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==, tarball: https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz} + '@esbuild/sunos-x64@0.25.0': + resolution: {integrity: sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - requiresBuild: true - dev: true - optional: true - /@esbuild/win32-arm64@0.25.4: - resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==, tarball: https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz} + '@esbuild/win32-arm64@0.25.0': + resolution: {integrity: sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - requiresBuild: true - dev: true - optional: true - /@esbuild/win32-ia32@0.25.4: - resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==, tarball: https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz} + '@esbuild/win32-ia32@0.25.0': + resolution: {integrity: sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - requiresBuild: true - dev: true - optional: true - /@esbuild/win32-x64@0.25.4: - resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==, tarball: https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz} + '@esbuild/win32-x64@0.25.0': + resolution: {integrity: sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==} engines: {node: '>=18'} cpu: [x64] os: [win32] - requiresBuild: true - dev: true - optional: true - /@eslint-community/eslint-utils@4.7.0(eslint@9.26.0): - resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==, tarball: https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - dependencies: - eslint: 9.26.0 - eslint-visitor-keys: 3.4.3 - dev: true - /@eslint-community/regexpp@4.12.1: - resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==, tarball: https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz} + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - dev: true - /@eslint/config-array@0.20.0: - resolution: {integrity: sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==, tarball: https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.20.0.tgz} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - dependencies: - '@eslint/object-schema': 2.1.6 - debug: 4.4.0 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color - dev: true - /@eslint/config-helpers@0.2.2: - resolution: {integrity: sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==, tarball: https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.2.2.tgz} + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - dev: true - /@eslint/core@0.13.0: - resolution: {integrity: sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==, tarball: https://registry.npmmirror.com/@eslint/core/-/core-0.13.0.tgz} + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - dependencies: - '@types/json-schema': 7.0.15 - dev: true - /@eslint/eslintrc@3.3.1: - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==, tarball: https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.1.tgz} + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - dependencies: - ajv: 6.12.6 - debug: 4.4.0 - espree: 10.3.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.0 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - dev: true - /@eslint/js@9.26.0: - resolution: {integrity: sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==, tarball: https://registry.npmmirror.com/@eslint/js/-/js-9.26.0.tgz} + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - dev: true - /@eslint/object-schema@2.1.6: - resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==, tarball: https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.6.tgz} + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - dev: true - /@eslint/plugin-kit@0.2.8: - resolution: {integrity: sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==, tarball: https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz} + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - dependencies: - '@eslint/core': 0.13.0 - levn: 0.4.1 - dev: true - /@floating-ui/core@1.7.0: - resolution: {integrity: sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==, tarball: https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.0.tgz} - dependencies: - '@floating-ui/utils': 0.2.9 - dev: false + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} - /@floating-ui/dom@1.7.0: - resolution: {integrity: sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==, tarball: https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.0.tgz} - dependencies: - '@floating-ui/core': 1.7.0 - '@floating-ui/utils': 0.2.9 - dev: false + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + + '@fastify/cookie@11.1.2': + resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==} + + '@fastify/deepmerge@3.2.1': + resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.1.0': + resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} - /@floating-ui/utils@0.2.9: - resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==, tarball: https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.9.tgz} - dev: false + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} - /@humanfs/core@0.19.1: - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==, tarball: https://registry.npmmirror.com/@humanfs/core/-/core-0.19.1.tgz} + '@fastify/helmet@13.1.0': + resolution: {integrity: sha512-SvVOU0IrzYJW1BvSkfq9G1WUdW3dnaRUvg6m0BtgGMBmML62No0VmSu087jecH58SFbicbREgZTPJ89mAguupA==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/multipart@10.1.0': + resolution: {integrity: sha512-b2r6CovmLQvLFJ5HJDtxVigZjcO9TwkRGslhiNQxsGJwilm5B3eqvXPOVLudC44zXMXJITpQ/iEATIE7zoXqcQ==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/rate-limit@11.1.0': + resolution: {integrity: sha512-BeJ9tizLvmTXGD7deYU5G04OtHhwk5uHxbpEPVp09gKvUBIXmau/4Bshxhu9ci54MvVWfGjCEx4RzvsTntojwA==} + + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/static@10.1.0': + resolution: {integrity: sha512-iK/8TvRM/EgNOyQL+EpWu+x3aR6o4GWt+UI+27zmE7w6t/6d80mXqOtWLdEKQ13vL/g1Jry0ae2icj6GP7tGzA==} + + '@fastify/websocket@11.3.0': + resolution: {integrity: sha512-g89ag4BCcD9YP5wBZXixzoLnuf5j89p/sXFcfpCiv2pdEkYYukBEoK3heVzqsp0EAtszVDc2BBZG0KZqeAShIA==} + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} - dev: true - /@humanfs/node@0.16.6: - resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==, tarball: https://registry.npmmirror.com/@humanfs/node/-/node-0.16.6.tgz} + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} engines: {node: '>=18.18.0'} - dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.3.1 - dev: true - /@humanwhocodes/module-importer@1.0.1: - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==, tarball: https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz} + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} - dev: true - /@humanwhocodes/retry@0.3.1: - resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==, tarball: https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.3.1.tgz} + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - dev: true - /@humanwhocodes/retry@0.4.3: - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==, tarball: https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz} - engines: {node: '>=18.18'} - dev: true + '@intlify/core-base@11.4.6': + resolution: {integrity: sha512-EOeHO95XESK9IFHgHeZXunsM/WBAoCA0DlaWODvx14vKmetAuS97t+l6Xe9hTUqntPpF93vtVSjjUDafw3wXMw==} + engines: {node: '>= 22'} - /@intlify/core-base@11.0.0-rc.1: - resolution: {integrity: sha512-fnfZoa9pb1dKM3L1UkDUGLLrPFQ2BK98x4/fMqwS/fktUor34vQR/itPtfv652ZTplenXXLCEYjUYTGfDZgMTQ==, tarball: https://registry.npmmirror.com/@intlify/core-base/-/core-base-11.0.0-rc.1.tgz} - engines: {node: '>= 16'} - dependencies: - '@intlify/message-compiler': 11.0.0-rc.1 - '@intlify/shared': 11.0.0-rc.1 - dev: false + '@intlify/devtools-types@11.4.6': + resolution: {integrity: sha512-wowQPpNem56b2d43IJmqbrzG2FeBKe5f/kUGlpNuBmXs6OSqncF8m1+1lxHuW8ISZJF0ma2RkW3iLkw0g0G4VA==} + engines: {node: '>= 22'} - /@intlify/message-compiler@11.0.0-rc.1: - resolution: {integrity: sha512-TGw2uBfuTFTegZf/BHtUQBEKxl7Q/dVGLoqRIdw8lFsp9g/53sYn5iD+0HxIzdYjbWL6BTJMXCPUHp9PxDTRPw==, tarball: https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-11.0.0-rc.1.tgz} - engines: {node: '>= 16'} - dependencies: - '@intlify/shared': 11.0.0-rc.1 - source-map-js: 1.2.1 - dev: false + '@intlify/message-compiler@11.4.6': + resolution: {integrity: sha512-5nj3jULqeTAC1WovwMs1LQWgatTa2pM/rXN9T3XW8rdOtXW9ZF6/GLSNFTKDQmPLwclhPdgUWLJ/4w3fMeeC/Q==} + engines: {node: '>= 22'} + + '@intlify/shared@11.4.6': + resolution: {integrity: sha512-m1p1HHAMLhqSpTRH7VnXdrN0CQ4y+9vunFkpLkbD8soIuBsnQdawZXqMCgvwI2UVF9Ww7sVaw7g9tV2VO7shoA==} + engines: {node: '>= 22'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} - /@intlify/shared@11.0.0-rc.1: - resolution: {integrity: sha512-8tR1xe7ZEbkabTuE/tNhzpolygUn9OaYp9yuYAF4MgDNZg06C3Qny80bes2/e9/Wm3aVkPUlCw6WgU7mQd0yEg==, tarball: https://registry.npmmirror.com/@intlify/shared/-/shared-11.0.0-rc.1.tgz} - engines: {node: '>= 16'} - dev: false + '@jridgewell/gen-mapping@0.3.12': + resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} - /@jridgewell/gen-mapping@0.3.8: - resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==, tarball: https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz} + '@jridgewell/gen-mapping@0.3.5': + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} - dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 - dev: true - /@jridgewell/resolve-uri@3.1.2: - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==, tarball: https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz} + '@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'} - dev: true - /@jridgewell/set-array@1.2.1: - resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==, tarball: https://registry.npmmirror.com/@jridgewell/set-array/-/set-array-1.2.1.tgz} + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} engines: {node: '>=6.0.0'} - dev: true - /@jridgewell/sourcemap-codec@1.5.0: - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==, tarball: https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz} + '@jridgewell/sourcemap-codec@1.4.10': + resolution: {integrity: sha512-Ht8wIW5v165atIX1p+JvKR5ONzUyF4Ac8DZIQ5kZs9zrb6M8SJNXpx1zn04rn65VjBMygRoMXcyYwNK0fT7bEg==} - /@jridgewell/trace-mapping@0.3.25: - resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==, tarball: https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz} - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 - dev: true + '@jridgewell/sourcemap-codec@1.4.14': + resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} - /@modelcontextprotocol/sdk@1.11.2: - resolution: {integrity: sha512-H9vwztj5OAqHg9GockCQC06k1natgcxWQSRpQcPJf6i5+MWBzfKkRtxGbjQf0X2ihii0ffLZCRGbYV2f2bjNCQ==, tarball: https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.11.2.tgz} - engines: {node: '>=18'} - dependencies: - content-type: 1.0.5 - cors: 2.8.5 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - express: 5.1.0 - express-rate-limit: 7.5.0(express@5.1.0) - pkce-challenge: 5.0.0 - raw-body: 3.0.0 - zod: 3.24.4 - zod-to-json-schema: 3.24.5(zod@3.24.4) - transitivePeerDependencies: - - supports-color - dev: true + '@jridgewell/sourcemap-codec@1.4.15': + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - /@nodelib/fs.scandir@2.1.5: - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==, tarball: https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - dev: true - /@nodelib/fs.stat@2.0.5: - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==, tarball: https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz} + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} engines: {node: '>= 8'} - dev: true - /@nodelib/fs.walk@1.2.8: - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==, tarball: https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz} + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 - dev: true - /@nuxt/kit@3.17.2: - resolution: {integrity: sha512-Mz2Ni8iUwty5LBs3LepUL43rI2xXbuAz3Cqq37L9frOD2QI2tQUtasYaSoKk6U7nvYzuW2z/2b3YOLkMNi/k2w==, tarball: https://registry.npmmirror.com/@nuxt/kit/-/kit-3.17.2.tgz} + '@nuxt/kit@3.21.8': + resolution: {integrity: sha512-kg63DUPY5AHPn+9XM7u8rYcdWHXjzwfUscgRDuiC5YUciQ+xdLRhdwXelYFxEAx2nxJHossliiQXbMm/Fleivw==} engines: {node: '>=18.12.0'} - dependencies: - c12: 3.0.3 - consola: 3.4.2 - defu: 6.1.4 - destr: 2.0.5 - errx: 0.1.0 - exsolve: 1.0.5 - ignore: 7.0.4 - jiti: 2.4.2 - klona: 2.0.6 - knitwork: 1.2.0 - mlly: 1.7.4 - ohash: 2.0.11 - pathe: 2.0.3 - pkg-types: 2.1.0 - scule: 1.3.0 - semver: 7.7.1 - std-env: 3.9.0 - tinyglobby: 0.2.13 - ufo: 1.6.1 - unctx: 2.4.1 - unimport: 5.0.1 - untyped: 2.0.0 - transitivePeerDependencies: - - magicast - dev: false - /@parcel/watcher-android-arm64@2.5.1: - resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==, tarball: https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz} + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [android] - requiresBuild: true - dev: true - optional: true - /@parcel/watcher-darwin-arm64@2.5.1: - resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==, tarball: https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz} + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [darwin] - requiresBuild: true - dev: true - optional: true - /@parcel/watcher-darwin-x64@2.5.1: - resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==, tarball: https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz} + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [darwin] - requiresBuild: true - dev: true - optional: true - /@parcel/watcher-freebsd-x64@2.5.1: - resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==, tarball: https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz} + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [freebsd] - requiresBuild: true - dev: true - optional: true - /@parcel/watcher-linux-arm-glibc@2.5.1: - resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==, tarball: https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz} + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] libc: [glibc] - requiresBuild: true - dev: true - optional: true - /@parcel/watcher-linux-arm-musl@2.5.1: - resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==, tarball: https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz} + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] libc: [musl] - requiresBuild: true - dev: true - optional: true - /@parcel/watcher-linux-arm64-glibc@2.5.1: - resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==, tarball: https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz} + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - requiresBuild: true - dev: true - optional: true - /@parcel/watcher-linux-arm64-musl@2.5.1: - resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==, tarball: https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz} + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] libc: [musl] - requiresBuild: true - dev: true - optional: true - /@parcel/watcher-linux-x64-glibc@2.5.1: - resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==, tarball: https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz} + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] libc: [glibc] - requiresBuild: true - dev: true - optional: true - /@parcel/watcher-linux-x64-musl@2.5.1: - resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==, tarball: https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz} + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] libc: [musl] - requiresBuild: true - dev: true - optional: true - /@parcel/watcher-win32-arm64@2.5.1: - resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==, tarball: https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz} + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [win32] - requiresBuild: true - dev: true - optional: true - /@parcel/watcher-win32-ia32@2.5.1: - resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==, tarball: https://registry.npmmirror.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz} + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} engines: {node: '>= 10.0.0'} cpu: [ia32] os: [win32] - requiresBuild: true - dev: true - optional: true - /@parcel/watcher-win32-x64@2.5.1: - resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==, tarball: https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz} + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [win32] - requiresBuild: true - dev: true - optional: true - /@parcel/watcher@2.5.1: - resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==, tarball: https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.1.tgz} + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} - requiresBuild: true - dependencies: - detect-libc: 1.0.3 - is-glob: 4.0.3 - micromatch: 4.0.8 - node-addon-api: 7.1.1 - optionalDependencies: - '@parcel/watcher-android-arm64': 2.5.1 - '@parcel/watcher-darwin-arm64': 2.5.1 - '@parcel/watcher-darwin-x64': 2.5.1 - '@parcel/watcher-freebsd-x64': 2.5.1 - '@parcel/watcher-linux-arm-glibc': 2.5.1 - '@parcel/watcher-linux-arm-musl': 2.5.1 - '@parcel/watcher-linux-arm64-glibc': 2.5.1 - '@parcel/watcher-linux-arm64-musl': 2.5.1 - '@parcel/watcher-linux-x64-glibc': 2.5.1 - '@parcel/watcher-linux-x64-musl': 2.5.1 - '@parcel/watcher-win32-arm64': 2.5.1 - '@parcel/watcher-win32-ia32': 2.5.1 - '@parcel/watcher-win32-x64': 2.5.1 - dev: true - optional: true - - /@pkgr/core@0.2.4: - resolution: {integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==, tarball: https://registry.npmmirror.com/@pkgr/core/-/core-0.2.4.tgz} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - dev: true - - /@polka/url@1.0.0-next.29: - resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==, tarball: https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.29.tgz} - dev: true - - /@rollup/pluginutils@5.1.4: - resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==, tarball: https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.1.4.tgz} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@pkgr/core@0.3.6': + resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} + engines: {node: ^14.18.0 || >=16.0.0} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} engines: {node: '>=14.0.0'} peerDependencies: rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 peerDependenciesMeta: rollup: optional: true - dependencies: - '@types/estree': 1.0.7 - estree-walker: 2.0.2 - picomatch: 4.0.2 - dev: true - /@rollup/rollup-android-arm-eabi@4.40.2: - resolution: {integrity: sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==, tarball: https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz} + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] os: [android] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-android-arm64@4.40.2: - resolution: {integrity: sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==, tarball: https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.2.tgz} + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} cpu: [arm64] os: [android] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-darwin-arm64@4.40.2: - resolution: {integrity: sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==, tarball: https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.2.tgz} + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} cpu: [arm64] os: [darwin] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-darwin-x64@4.40.2: - resolution: {integrity: sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==, tarball: https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.2.tgz} + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} cpu: [x64] os: [darwin] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-freebsd-arm64@4.40.2: - resolution: {integrity: sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==, tarball: https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.2.tgz} + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} cpu: [arm64] os: [freebsd] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-freebsd-x64@4.40.2: - resolution: {integrity: sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==, tarball: https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.2.tgz} + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} cpu: [x64] os: [freebsd] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-linux-arm-gnueabihf@4.40.2: - resolution: {integrity: sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.2.tgz} + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} cpu: [arm] os: [linux] libc: [glibc] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-linux-arm-musleabihf@4.40.2: - resolution: {integrity: sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.2.tgz} + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} cpu: [arm] os: [linux] libc: [musl] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-linux-arm64-gnu@4.40.2: - resolution: {integrity: sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.2.tgz} + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} cpu: [arm64] os: [linux] libc: [glibc] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-linux-arm64-musl@4.40.2: - resolution: {integrity: sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.2.tgz} + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} cpu: [arm64] os: [linux] libc: [musl] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-linux-loongarch64-gnu@4.40.2: - resolution: {integrity: sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.2.tgz} + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} cpu: [loong64] os: [linux] libc: [glibc] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-linux-powerpc64le-gnu@4.40.2: - resolution: {integrity: sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.2.tgz} + '@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] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-linux-riscv64-gnu@4.40.2: - resolution: {integrity: sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.2.tgz} + '@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] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-linux-riscv64-musl@4.40.2: - resolution: {integrity: sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.2.tgz} + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} cpu: [riscv64] os: [linux] libc: [musl] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-linux-s390x-gnu@4.40.2: - resolution: {integrity: sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.2.tgz} + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} cpu: [s390x] os: [linux] libc: [glibc] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-linux-x64-gnu@4.40.2: - resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.2.tgz} + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} cpu: [x64] os: [linux] libc: [glibc] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-linux-x64-musl@4.40.2: - resolution: {integrity: sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.2.tgz} + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} cpu: [x64] os: [linux] libc: [musl] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-win32-arm64-msvc@4.40.2: - resolution: {integrity: sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==, tarball: https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.2.tgz} + '@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] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-win32-ia32-msvc@4.40.2: - resolution: {integrity: sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==, tarball: https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.2.tgz} + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} cpu: [ia32] os: [win32] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-win32-x64-msvc@4.40.2: - resolution: {integrity: sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==, tarball: https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.2.tgz} + '@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] - requiresBuild: true - dev: true - optional: true - /@sec-ant/readable-stream@0.4.1: - resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==, tarball: https://registry.npmmirror.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz} - dev: true + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - /@sindresorhus/merge-streams@4.0.0: - resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==, tarball: https://registry.npmmirror.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - dev: true - /@sxzz/popperjs-es@2.11.7: - resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==, tarball: https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz} - dev: false + '@sxzz/popperjs-es@2.11.8': + resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} - /@types/estree@1.0.7: - resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==, tarball: https://registry.npmmirror.com/@types/estree/-/estree-1.0.7.tgz} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - /@types/json-schema@7.0.15: - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==, tarball: https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz} - dev: true + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - /@types/lodash-es@4.17.12: - resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==, tarball: https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz} - dependencies: - '@types/lodash': 4.17.16 - dev: false + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} - /@types/lodash@4.17.16: - resolution: {integrity: sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==, tarball: https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.16.tgz} - dev: false + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} - /@types/web-bluetooth@0.0.16: - resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==, tarball: https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz} - dev: false + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} - /@vitejs/plugin-vue@5.2.4(vite@6.3.5)(vue@3.5.13): - resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==, tarball: https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz} + '@vitejs/plugin-vue@5.2.1': + resolution: {integrity: sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==} engines: {node: ^18.0.0 || >=20.0.0} peerDependencies: vite: ^5.0.0 || ^6.0.0 vue: ^3.2.25 - dependencies: - vite: 6.3.5(sass@1.88.0)(yaml@2.7.1) - vue: 3.5.13 - dev: true - /@vue/babel-helper-vue-transform-on@1.4.0: - resolution: {integrity: sha512-mCokbouEQ/ocRce/FpKCRItGo+013tHg7tixg3DUNS+6bmIchPt66012kBMm476vyEIJPafrvOf4E5OYj3shSw==, tarball: https://registry.npmmirror.com/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.4.0.tgz} - dev: true + '@vue/babel-helper-vue-transform-on@1.1.5': + resolution: {integrity: sha512-SgUymFpMoAyWeYWLAY+MkCK3QEROsiUnfaw5zxOVD/M64KQs8D/4oK6Q5omVA2hnvEOE0SCkH2TZxs/jnnUj7w==} - /@vue/babel-plugin-jsx@1.4.0(@babel/core@7.27.1): - resolution: {integrity: sha512-9zAHmwgMWlaN6qRKdrg1uKsBKHvnUU+Py+MOCTuYZBoZsopa90Di10QRjB+YPnVss0BZbG/H5XFwJY1fTxJWhA==, tarball: https://registry.npmmirror.com/@vue/babel-plugin-jsx/-/babel-plugin-jsx-1.4.0.tgz} + '@vue/babel-plugin-jsx@1.1.5': + resolution: {integrity: sha512-nKs1/Bg9U1n3qSWnsHhCVQtAzI6aQXqua8j/bZrau8ywT1ilXQbK4FwEJGmU8fV7tcpuFvWmmN7TMmV1OBma1g==} peerDependencies: '@babel/core': ^7.0.0-0 - peerDependenciesMeta: - '@babel/core': - optional: true - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.27.1) - '@babel/template': 7.27.2 - '@babel/traverse': 7.27.1 - '@babel/types': 7.27.1 - '@vue/babel-helper-vue-transform-on': 1.4.0 - '@vue/babel-plugin-resolve-type': 1.4.0(@babel/core@7.27.1) - '@vue/shared': 3.5.13 - transitivePeerDependencies: - - supports-color - dev: true - /@vue/babel-plugin-resolve-type@1.4.0(@babel/core@7.27.1): - resolution: {integrity: sha512-4xqDRRbQQEWHQyjlYSgZsWj44KfiF6D+ktCuXyZ8EnVDYV3pztmXJDf1HveAjUAXxAnR8daCQT51RneWWxtTyQ==, tarball: https://registry.npmmirror.com/@vue/babel-plugin-resolve-type/-/babel-plugin-resolve-type-1.4.0.tgz} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/core': 7.27.1 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/parser': 7.27.2 - '@vue/compiler-sfc': 3.5.13 - transitivePeerDependencies: - - supports-color - dev: true + '@vue/compiler-core@3.5.40': + resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==} - /@vue/compiler-core@3.5.13: - resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==, tarball: https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.13.tgz} - dependencies: - '@babel/parser': 7.27.2 - '@vue/shared': 3.5.13 - entities: 4.5.0 - estree-walker: 2.0.2 - source-map-js: 1.2.1 + '@vue/compiler-dom@3.5.40': + resolution: {integrity: sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==} - /@vue/compiler-dom@3.5.13: - resolution: {integrity: sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==, tarball: https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz} - dependencies: - '@vue/compiler-core': 3.5.13 - '@vue/shared': 3.5.13 + '@vue/compiler-sfc@3.5.40': + resolution: {integrity: sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==} - /@vue/compiler-sfc@3.5.13: - resolution: {integrity: sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==, tarball: https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz} - dependencies: - '@babel/parser': 7.27.2 - '@vue/compiler-core': 3.5.13 - '@vue/compiler-dom': 3.5.13 - '@vue/compiler-ssr': 3.5.13 - '@vue/shared': 3.5.13 - estree-walker: 2.0.2 - magic-string: 0.30.17 - postcss: 8.5.3 - source-map-js: 1.2.1 + '@vue/compiler-ssr@3.5.40': + resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==} - /@vue/compiler-ssr@3.5.13: - resolution: {integrity: sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==, tarball: https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz} - dependencies: - '@vue/compiler-dom': 3.5.13 - '@vue/shared': 3.5.13 + '@vue/devtools-api@6.5.0': + resolution: {integrity: sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q==} - /@vue/devtools-api@6.6.4: - resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==, tarball: https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz} - dev: false + '@vue/devtools-api@6.6.3': + resolution: {integrity: sha512-0MiMsFma/HqA6g3KLKn+AGpL1kgKhFWszC9U29NfpWK5LE7bjeXxySWJrOJ77hBz+TBrBQ7o4QJqbPbqbs8rJw==} - /@vue/devtools-core@7.7.6(vite@6.3.5)(vue@3.5.13): - resolution: {integrity: sha512-ghVX3zjKPtSHu94Xs03giRIeIWlb9M+gvDRVpIZ/cRIxKHdW6HE/sm1PT3rUYS3aV92CazirT93ne+7IOvGUWg==, tarball: https://registry.npmmirror.com/@vue/devtools-core/-/devtools-core-7.7.6.tgz} + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/devtools-core@7.7.10': + resolution: {integrity: sha512-rpsAY7HuxYUz6G7R0zBGoOgYsn/r3iU0LixLOGNJ3o5if6tkPDGIGV+6CIJbDNbeueP2HsGDCOQ5ni6Wr36xEQ==} peerDependencies: vue: ^3.0.0 - dependencies: - '@vue/devtools-kit': 7.7.6 - '@vue/devtools-shared': 7.7.6 - mitt: 3.0.1 - nanoid: 5.1.5 - pathe: 2.0.3 - vite-hot-client: 2.0.4(vite@6.3.5) - vue: 3.5.13 - transitivePeerDependencies: - - vite - dev: true - /@vue/devtools-kit@7.7.6: - resolution: {integrity: sha512-geu7ds7tem2Y7Wz+WgbnbZ6T5eadOvozHZ23Atk/8tksHMFOFylKi1xgGlQlVn0wlkEf4hu+vd5ctj1G4kFtwA==, tarball: https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.6.tgz} - dependencies: - '@vue/devtools-shared': 7.7.6 - birpc: 2.3.0 - hookable: 5.5.3 - mitt: 3.0.1 - perfect-debounce: 1.0.0 - speakingurl: 14.0.1 - superjson: 2.2.2 - dev: true + '@vue/devtools-kit@7.7.10': + resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} - /@vue/devtools-shared@7.7.6: - resolution: {integrity: sha512-yFEgJZ/WblEsojQQceuyK6FzpFDx4kqrz2ohInxNj5/DnhoX023upTv4OD6lNPLAA5LLkbwPVb10o/7b+Y4FVA==, tarball: https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.6.tgz} - dependencies: - rfdc: 1.4.1 - dev: true + '@vue/devtools-shared@7.7.10': + resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} - /@vue/eslint-config-prettier@10.2.0(eslint@9.26.0)(prettier@3.5.3): - resolution: {integrity: sha512-GL3YBLwv/+b86yHcNNfPJxOTtVFJ4Mbc9UU3zR+KVoG7SwGTjPT+32fXamscNumElhcpXW3mT0DgzS9w32S7Bw==, tarball: https://registry.npmmirror.com/@vue/eslint-config-prettier/-/eslint-config-prettier-10.2.0.tgz} + '@vue/eslint-config-prettier@10.2.0': + resolution: {integrity: sha512-GL3YBLwv/+b86yHcNNfPJxOTtVFJ4Mbc9UU3zR+KVoG7SwGTjPT+32fXamscNumElhcpXW3mT0DgzS9w32S7Bw==} peerDependencies: eslint: '>= 8.21.0' prettier: '>= 3.0.0' - dependencies: - eslint: 9.26.0 - eslint-config-prettier: 10.1.5(eslint@9.26.0) - eslint-plugin-prettier: 5.4.0(eslint-config-prettier@10.1.5)(eslint@9.26.0)(prettier@3.5.3) - prettier: 3.5.3 - transitivePeerDependencies: - - '@types/eslint' - dev: true - /@vue/reactivity@3.5.13: - resolution: {integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==, tarball: https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.13.tgz} - dependencies: - '@vue/shared': 3.5.13 + '@vue/reactivity@3.5.40': + resolution: {integrity: sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==} - /@vue/runtime-core@3.5.13: - resolution: {integrity: sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==, tarball: https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.13.tgz} - dependencies: - '@vue/reactivity': 3.5.13 - '@vue/shared': 3.5.13 + '@vue/runtime-core@3.5.40': + resolution: {integrity: sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==} - /@vue/runtime-dom@3.5.13: - resolution: {integrity: sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==, tarball: https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz} - dependencies: - '@vue/reactivity': 3.5.13 - '@vue/runtime-core': 3.5.13 - '@vue/shared': 3.5.13 - csstype: 3.1.3 + '@vue/runtime-dom@3.5.40': + resolution: {integrity: sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==} - /@vue/server-renderer@3.5.13(vue@3.5.13): - resolution: {integrity: sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==, tarball: https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.13.tgz} - peerDependencies: - vue: 3.5.13 - dependencies: - '@vue/compiler-ssr': 3.5.13 - '@vue/shared': 3.5.13 - vue: 3.5.13 + '@vue/server-renderer@3.5.40': + resolution: {integrity: sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==} - /@vue/shared@3.5.13: - resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==, tarball: https://registry.npmmirror.com/@vue/shared/-/shared-3.5.13.tgz} + '@vue/shared@3.5.40': + resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==} - /@vueuse/core@9.13.0(vue@3.5.13): - resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==, tarball: https://registry.npmmirror.com/@vueuse/core/-/core-9.13.0.tgz} - dependencies: - '@types/web-bluetooth': 0.0.16 - '@vueuse/metadata': 9.13.0 - '@vueuse/shared': 9.13.0(vue@3.5.13) - vue-demi: 0.14.10(vue@3.5.13) - transitivePeerDependencies: - - '@vue/composition-api' - - vue - dev: false + '@vueuse/core@14.3.0': + resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==} + peerDependencies: + vue: ^3.5.0 - /@vueuse/metadata@9.13.0: - resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==, tarball: https://registry.npmmirror.com/@vueuse/metadata/-/metadata-9.13.0.tgz} - dev: false + '@vueuse/metadata@14.3.0': + resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==} - /@vueuse/shared@9.13.0(vue@3.5.13): - resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==, tarball: https://registry.npmmirror.com/@vueuse/shared/-/shared-9.13.0.tgz} - dependencies: - vue-demi: 0.14.10(vue@3.5.13) - transitivePeerDependencies: - - '@vue/composition-api' - - vue - dev: false + '@vueuse/shared@14.3.0': + resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==} + peerDependencies: + vue: ^3.5.0 - /accepts@2.0.0: - resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==, tarball: https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz} - engines: {node: '>= 0.6'} - dependencies: - mime-types: 3.0.1 - negotiator: 1.0.0 - dev: true + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} - /acorn-jsx@5.3.2(acorn@8.14.1): - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==, tarball: https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - dependencies: - acorn: 8.14.1 - dev: true - /acorn-node@1.8.2: - resolution: {integrity: sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==, tarball: https://registry.npmmirror.com/acorn-node/-/acorn-node-1.8.2.tgz} - dependencies: - acorn: 7.4.1 - acorn-walk: 7.2.0 - xtend: 4.0.2 - dev: true + acorn-node@1.8.2: + resolution: {integrity: sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==} - /acorn-walk@7.2.0: - resolution: {integrity: sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==, tarball: https://registry.npmmirror.com/acorn-walk/-/acorn-walk-7.2.0.tgz} + acorn-walk@7.0.0: + resolution: {integrity: sha512-7Bv1We7ZGuU79zZbb6rRqcpxo3OY+zrdtloZWoyD8fmGX+FeXRjE+iuGkZjSXLVovLzrsvMGMy0EkwA0E0umxg==} engines: {node: '>=0.4.0'} - dev: true - /acorn@7.4.1: - resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==, tarball: https://registry.npmmirror.com/acorn/-/acorn-7.4.1.tgz} + acorn@7.0.0: + resolution: {integrity: sha512-PaF/MduxijYYt7unVGRuds1vBC9bFxbNf+VWqhOClfdgy7RlVkQqt610ig1/yxTgsDIfW1cWDel5EBbOy3jdtQ==} engines: {node: '>=0.4.0'} hasBin: true - dev: true - /acorn@8.14.1: - resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==, tarball: https://registry.npmmirror.com/acorn/-/acorn-8.14.1.tgz} + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} engines: {node: '>=0.4.0'} hasBin: true - /ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==, tarball: https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz} - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - dev: true + agent-base@6.0.0: + resolution: {integrity: sha512-j1Q7cSCqN+AwrmDd+pzgqc0/NpC655x2bUf5ZjRIO77DcNBFmh+OgRNzF6OKdCC9RSCb19fGd99+bhXFdkRNqw==} + engines: {node: '>= 6.0.0'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - /ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==, tarball: https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - dependencies: - color-convert: 2.0.1 - dev: true - /anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==, tarball: https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - dev: true - /arg@5.0.2: - resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==, tarball: https://registry.npmmirror.com/arg/-/arg-5.0.2.tgz} - dev: true + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - /argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==, tarball: https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz} - dev: true + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - /async-validator@4.2.5: - resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==, tarball: https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz} - dev: false + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} - /asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==, tarball: https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz} - dev: false + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - /autoprefixer@10.4.21(postcss@8.5.3): - resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==, tarball: https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.21.tgz} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + autoprefixer@10.5.4: + resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: postcss: ^8.1.0 - dependencies: - browserslist: 4.24.5 - caniuse-lite: 1.0.30001717 - fraction.js: 4.3.7 - normalize-range: 0.1.2 - picocolors: 1.1.1 - postcss: 8.5.3 - postcss-value-parser: 4.2.0 - dev: true - /axios@1.9.0: - resolution: {integrity: sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==, tarball: https://registry.npmmirror.com/axios/-/axios-1.9.0.tgz} - dependencies: - follow-redirects: 1.15.9 - form-data: 4.0.2 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - dev: false + avvio@9.3.0: + resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + + balanced-match@1.0.0: + resolution: {integrity: sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.43: + resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} + engines: {node: '>=6.0.0'} + hasBin: true - /balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==, tarball: https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz} - dev: true + better-sqlite3@12.11.1: + resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} - /binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==, tarball: https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz} + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - dev: true - /birpc@2.3.0: - resolution: {integrity: sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g==, tarball: https://registry.npmmirror.com/birpc/-/birpc-2.3.0.tgz} - dev: true + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - /body-parser@2.2.0: - resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==, tarball: https://registry.npmmirror.com/body-parser/-/body-parser-2.2.0.tgz} - engines: {node: '>=18'} - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 4.4.0 - http-errors: 2.0.0 - iconv-lite: 0.6.3 - on-finished: 2.4.1 - qs: 6.14.0 - raw-body: 3.0.0 - type-is: 2.0.1 - transitivePeerDependencies: - - supports-color - dev: true + birpc@2.3.0: + resolution: {integrity: sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g==} - /boolbase@1.0.0: - resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==, tarball: https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz} - dev: true + bl@4.0.3: + resolution: {integrity: sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg==} - /brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==, tarball: https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz} - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - dev: true + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} + + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} - /braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==, tarball: https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - dependencies: - fill-range: 7.1.1 - dev: true - /browserslist@4.24.5: - resolution: {integrity: sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==, tarball: https://registry.npmmirror.com/browserslist/-/browserslist-4.24.5.tgz} + browserslist@4.28.6: + resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - dependencies: - caniuse-lite: 1.0.30001717 - electron-to-chromium: 1.5.151 - node-releases: 2.0.19 - update-browserslist-db: 1.1.3(browserslist@4.24.5) - dev: true - /bundle-name@4.1.0: - resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==, tarball: https://registry.npmmirror.com/bundle-name/-/bundle-name-4.1.0.tgz} - engines: {node: '>=18'} - dependencies: - run-applescript: 7.0.0 - dev: true + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - /bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==, tarball: https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz} - engines: {node: '>= 0.8'} - dev: true + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} - /c12@3.0.3: - resolution: {integrity: sha512-uC3MacKBb0Z15o5QWCHvHWj5Zv34pGQj9P+iXKSpTuSGFS0KKhUWf4t9AJ+gWjYOdmWCPEGpEzm8sS0iqbpo1w==, tarball: https://registry.npmmirror.com/c12/-/c12-3.0.3.tgz} + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} peerDependencies: - magicast: ^0.3.5 + magicast: '*' peerDependenciesMeta: magicast: optional: true - dependencies: - chokidar: 4.0.3 - confbox: 0.2.2 - defu: 6.1.4 - dotenv: 16.5.0 - exsolve: 1.0.5 - giget: 2.0.0 - jiti: 2.4.2 - ohash: 2.0.11 - pathe: 2.0.3 - perfect-debounce: 1.0.0 - pkg-types: 2.1.0 - rc9: 2.1.2 - dev: false - - /call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==, tarball: https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz} - engines: {node: '>= 0.4'} - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - /call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==, tarball: https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - dev: true - /callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==, tarball: https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz} + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - dev: true - /camelcase-css@2.0.1: - resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==, tarball: https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz} + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - dev: true - /caniuse-lite@1.0.30001717: - resolution: {integrity: sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw==, tarball: https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001717.tgz} - dev: true + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} - /chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==, tarball: https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - dev: true - /chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==, tarball: https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - dev: true - /chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==, tarball: https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz} - engines: {node: '>= 14.16.0'} - dependencies: - readdirp: 4.1.2 + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} - /citty@0.1.6: - resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==, tarball: https://registry.npmmirror.com/citty/-/citty-0.1.6.tgz} - dependencies: - consola: 3.4.2 - dev: false + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} - /color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, tarball: https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz} + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} - dependencies: - color-name: 1.1.4 - dev: true - /color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==, tarball: https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz} - dev: true + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - /combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==, tarball: https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} - dependencies: - delayed-stream: 1.0.0 - dev: false - - /concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==, tarball: https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz} - dev: true - /confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==, tarball: https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz} - dev: false + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - /confbox@0.2.2: - resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==, tarball: https://registry.npmmirror.com/confbox/-/confbox-0.2.2.tgz} - dev: false + concurrently@10.0.3: + resolution: {integrity: sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==} + engines: {node: '>=22'} + hasBin: true - /consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==, tarball: https://registry.npmmirror.com/consola/-/consola-3.4.2.tgz} - engines: {node: ^14.18.0 || >=16.10.0} - dev: false + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - /content-disposition@1.0.0: - resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==, tarball: https://registry.npmmirror.com/content-disposition/-/content-disposition-1.0.0.tgz} - engines: {node: '>= 0.6'} - dependencies: - safe-buffer: 5.2.1 - dev: true + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} - /content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==, tarball: https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz} - engines: {node: '>= 0.6'} - dev: true + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} - /convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==, tarball: https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz} - dev: true + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} - /cookie-signature@1.2.2: - resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==, tarball: https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz} - engines: {node: '>=6.6.0'} - dev: true + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - /cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==, tarball: https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz} - engines: {node: '>= 0.6'} - dev: true + cookie@1.0.1: + resolution: {integrity: sha512-Xd8lFX4LM9QEEwxQpF9J9NTUh8pmdJO0cyRJhFiDoLTk2eH8FXlRv2IFGYVadZpqI3j8fhNrSdKCeYPxiAhLXw==} + engines: {node: '>=18'} - /copy-anything@3.0.5: - resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==, tarball: https://registry.npmmirror.com/copy-anything/-/copy-anything-3.0.5.tgz} - engines: {node: '>=12.13'} - dependencies: - is-what: 4.1.16 - dev: true + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} - /cors@2.8.5: - resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==, tarball: https://registry.npmmirror.com/cors/-/cors-2.8.5.tgz} - engines: {node: '>= 0.10'} - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - dev: true + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} - /cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, tarball: https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - dev: true - /cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==, tarball: https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz} + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true - dev: true - /csstype@3.1.3: - resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==, tarball: https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - /dayjs@1.11.13: - resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==, tarball: https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz} - dev: false + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} - /debug@4.4.0: - resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==, tarball: https://registry.npmmirror.com/debug/-/debug-4.4.0.tgz} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' peerDependenciesMeta: supports-color: optional: true - dependencies: - ms: 2.1.3 - dev: true - /deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==, tarball: https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz} - dev: true + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} - /deep-pick-omit@1.2.1: - resolution: {integrity: sha512-2J6Kc/m3irCeqVG42T+SaUMesaK7oGWaedGnQQK/+O0gYc+2SP5bKh/KKTE7d7SJ+GCA9UUE1GRzh6oDe0EnGw==, tarball: https://registry.npmmirror.com/deep-pick-omit/-/deep-pick-omit-1.2.1.tgz} - dev: false + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - /default-browser-id@5.0.0: - resolution: {integrity: sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==, tarball: https://registry.npmmirror.com/default-browser-id/-/default-browser-id-5.0.0.tgz} + deep-pick-omit@1.2.1: + resolution: {integrity: sha512-2J6Kc/m3irCeqVG42T+SaUMesaK7oGWaedGnQQK/+O0gYc+2SP5bKh/KKTE7d7SJ+GCA9UUE1GRzh6oDe0EnGw==} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} - dev: true - /default-browser@5.2.1: - resolution: {integrity: sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==, tarball: https://registry.npmmirror.com/default-browser/-/default-browser-5.2.1.tgz} + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} - dependencies: - bundle-name: 4.1.0 - default-browser-id: 5.0.0 - dev: true - /define-lazy-prop@3.0.0: - resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==, tarball: https://registry.npmmirror.com/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} - dev: true - /defined@1.0.1: - resolution: {integrity: sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==, tarball: https://registry.npmmirror.com/defined/-/defined-1.0.1.tgz} - dev: true + defined@1.0.1: + resolution: {integrity: sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==} - /defu@6.1.4: - resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==, tarball: https://registry.npmmirror.com/defu/-/defu-6.1.4.tgz} - dev: false + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} - /delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==, tarball: https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - dev: false - /depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==, tarball: https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - dev: true - /destr@2.0.5: - resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==, tarball: https://registry.npmmirror.com/destr/-/destr-2.0.5.tgz} - dev: false + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} - /detect-libc@1.0.3: - resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==, tarball: https://registry.npmmirror.com/detect-libc/-/detect-libc-1.0.3.tgz} - engines: {node: '>=0.10'} - hasBin: true - requiresBuild: true - dev: true - optional: true + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} - /detective@5.2.1: - resolution: {integrity: sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==, tarball: https://registry.npmmirror.com/detective/-/detective-5.2.1.tgz} + detective@5.2.1: + resolution: {integrity: sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==} engines: {node: '>=0.8.0'} hasBin: true - dependencies: - acorn-node: 1.8.2 - defined: 1.0.1 - minimist: 1.2.8 - dev: true - /didyoumean@1.2.2: - resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==, tarball: https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz} - dev: true + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - /dlv@1.1.3: - resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==, tarball: https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz} - dev: true + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - /dotenv@16.5.0: - resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==, tarball: https://registry.npmmirror.com/dotenv/-/dotenv-16.5.0.tgz} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} - dev: false - /dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==, tarball: https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - /ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==, tarball: https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz} - dev: true + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - /electron-to-chromium@1.5.151: - resolution: {integrity: sha512-Rl6uugut2l9sLojjS4H4SAr3A4IgACMLgpuEMPYCVcKydzfyPrn5absNRju38IhQOf/NwjJY8OGWjlteqYeBCA==, tarball: https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.151.tgz} - dev: true + electron-to-chromium@1.5.393: + resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} - /element-plus@2.9.10(vue@3.5.13): - resolution: {integrity: sha512-W2v9jWnm1kl/zm4bSvCh8aFCVlxvhG3fmqiDZwyd6WQiWGE595J/mpjcCggEr+49QDgIymhXrpPMOPPSARUbng==, tarball: https://registry.npmmirror.com/element-plus/-/element-plus-2.9.10.tgz} + element-plus@2.14.3: + resolution: {integrity: sha512-pJcvxcpZjYruNzuJhAeVwnbYjfNgzBKnWHwSVEhwzM2/kcLI3brzmtIBxtPqd4hQWJfD1PRnjoc1WipLw2eBGg==} peerDependencies: - vue: ^3.2.0 - dependencies: - '@ctrl/tinycolor': 3.6.1 - '@element-plus/icons-vue': 2.3.1(vue@3.5.13) - '@floating-ui/dom': 1.7.0 - '@popperjs/core': /@sxzz/popperjs-es@2.11.7 - '@types/lodash': 4.17.16 - '@types/lodash-es': 4.17.12 - '@vueuse/core': 9.13.0(vue@3.5.13) - async-validator: 4.2.5 - dayjs: 1.11.13 - escape-html: 1.0.3 - lodash: 4.17.21 - lodash-es: 4.17.21 - lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21) - memoize-one: 6.0.0 - normalize-wheel-es: 1.2.0 - vue: 3.5.13 - transitivePeerDependencies: - - '@vue/composition-api' - dev: false + vue: ^3.3.7 - /encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==, tarball: https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz} - engines: {node: '>= 0.8'} - dev: true + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - /entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==, tarball: https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} - /error-stack-parser-es@0.1.5: - resolution: {integrity: sha512-xHku1X40RO+fO8yJ8Wh2f2rZWVjqyhb1zgq1yZ8aZRQkv6OOKhKWRUaht3eSCUbAOBaKIgM+ykwFLE+QUxgGeg==, tarball: https://registry.npmmirror.com/error-stack-parser-es/-/error-stack-parser-es-0.1.5.tgz} - dev: true + error-stack-parser-es@0.1.5: + resolution: {integrity: sha512-xHku1X40RO+fO8yJ8Wh2f2rZWVjqyhb1zgq1yZ8aZRQkv6OOKhKWRUaht3eSCUbAOBaKIgM+ykwFLE+QUxgGeg==} - /errx@0.1.0: - resolution: {integrity: sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==, tarball: https://registry.npmmirror.com/errx/-/errx-0.1.0.tgz} - dev: false + errx@0.1.0: + resolution: {integrity: sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==} - /es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==, tarball: https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz} + 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==, tarball: https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz} + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - /es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==, tarball: https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} - dependencies: - es-errors: 1.3.0 - /es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==, tarball: https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - dev: false - /esbuild@0.25.4: - resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==, tarball: https://registry.npmmirror.com/esbuild/-/esbuild-0.25.4.tgz} + esbuild@0.25.0: + resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==} engines: {node: '>=18'} hasBin: true - requiresBuild: true - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.4 - '@esbuild/android-arm': 0.25.4 - '@esbuild/android-arm64': 0.25.4 - '@esbuild/android-x64': 0.25.4 - '@esbuild/darwin-arm64': 0.25.4 - '@esbuild/darwin-x64': 0.25.4 - '@esbuild/freebsd-arm64': 0.25.4 - '@esbuild/freebsd-x64': 0.25.4 - '@esbuild/linux-arm': 0.25.4 - '@esbuild/linux-arm64': 0.25.4 - '@esbuild/linux-ia32': 0.25.4 - '@esbuild/linux-loong64': 0.25.4 - '@esbuild/linux-mips64el': 0.25.4 - '@esbuild/linux-ppc64': 0.25.4 - '@esbuild/linux-riscv64': 0.25.4 - '@esbuild/linux-s390x': 0.25.4 - '@esbuild/linux-x64': 0.25.4 - '@esbuild/netbsd-arm64': 0.25.4 - '@esbuild/netbsd-x64': 0.25.4 - '@esbuild/openbsd-arm64': 0.25.4 - '@esbuild/openbsd-x64': 0.25.4 - '@esbuild/sunos-x64': 0.25.4 - '@esbuild/win32-arm64': 0.25.4 - '@esbuild/win32-ia32': 0.25.4 - '@esbuild/win32-x64': 0.25.4 - dev: true - - /escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==, tarball: https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - dev: true - /escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==, tarball: https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - /escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==, tarball: https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - dev: true - - /escape-string-regexp@5.0.0: - resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==, tarball: https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz} - engines: {node: '>=12'} - dev: false - /eslint-config-prettier@10.1.5(eslint@9.26.0): - resolution: {integrity: sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==, tarball: https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz} + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true peerDependencies: eslint: '>=7.0.0' - dependencies: - eslint: 9.26.0 - dev: true - /eslint-plugin-prettier@5.4.0(eslint-config-prettier@10.1.5)(eslint@9.26.0)(prettier@3.5.3): - resolution: {integrity: sha512-BvQOvUhkVQM1i63iMETK9Hjud9QhqBnbtT1Zc642p9ynzBuCe5pybkOnvqZIBypXmMlsGcnU4HZ8sCTPfpAexA==, tarball: https://registry.npmmirror.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.4.0.tgz} + eslint-plugin-prettier@5.5.6: + resolution: {integrity: sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: '@types/eslint': '>=8.0.0' @@ -2033,61 +1400,35 @@ packages: optional: true eslint-config-prettier: optional: true - dependencies: - eslint: 9.26.0 - eslint-config-prettier: 10.1.5(eslint@9.26.0) - prettier: 3.5.3 - prettier-linter-helpers: 1.0.0 - synckit: 0.11.4 - dev: true - /eslint-plugin-vue@9.33.0(eslint@9.26.0): - resolution: {integrity: sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==, tarball: https://registry.npmmirror.com/eslint-plugin-vue/-/eslint-plugin-vue-9.33.0.tgz} + eslint-plugin-vue@9.32.0: + resolution: {integrity: sha512-b/Y05HYmnB/32wqVcjxjHZzNpwxj1onBOvqW89W+V+XNG1dRuaFbNd3vT9CLbr2LXjEoq+3vn8DanWf7XU22Ug==} engines: {node: ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 - dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.26.0) - eslint: 9.26.0 - globals: 13.24.0 - natural-compare: 1.4.0 - nth-check: 2.1.1 - postcss-selector-parser: 6.1.2 - semver: 7.7.1 - vue-eslint-parser: 9.4.3(eslint@9.26.0) - xml-name-validator: 4.0.0 - transitivePeerDependencies: - - supports-color - dev: true - /eslint-scope@7.2.2: - resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==, tarball: https://registry.npmmirror.com/eslint-scope/-/eslint-scope-7.2.2.tgz} + eslint-scope@7.1.1: + resolution: {integrity: sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - dev: true - /eslint-scope@8.3.0: - resolution: {integrity: sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==, tarball: https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.3.0.tgz} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - dev: true - /eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==, tarball: https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz} + eslint-visitor-keys@3.3.0: + resolution: {integrity: sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dev: true - /eslint-visitor-keys@4.2.0: - resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==, tarball: https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz} + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - dev: true - /eslint@9.26.0: - resolution: {integrity: sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==, tarball: https://registry.npmmirror.com/eslint/-/eslint-9.26.0.tgz} + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -2095,1076 +1436,701 @@ packages: peerDependenciesMeta: jiti: optional: true - dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.26.0) - '@eslint-community/regexpp': 4.12.1 - '@eslint/config-array': 0.20.0 - '@eslint/config-helpers': 0.2.2 - '@eslint/core': 0.13.0 - '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.26.0 - '@eslint/plugin-kit': 0.2.8 - '@humanfs/node': 0.16.6 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@modelcontextprotocol/sdk': 1.11.2 - '@types/estree': 1.0.7 - '@types/json-schema': 7.0.15 - ajv: 6.12.6 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.0 - escape-string-regexp: 4.0.0 - eslint-scope: 8.3.0 - eslint-visitor-keys: 4.2.0 - espree: 10.3.0 - esquery: 1.6.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.2 - natural-compare: 1.4.0 - optionator: 0.9.4 - zod: 3.24.4 - transitivePeerDependencies: - - supports-color - dev: true - /espree@10.3.0: - resolution: {integrity: sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==, tarball: https://registry.npmmirror.com/espree/-/espree-10.3.0.tgz} + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - dependencies: - acorn: 8.14.1 - acorn-jsx: 5.3.2(acorn@8.14.1) - eslint-visitor-keys: 4.2.0 - dev: true - /espree@9.6.1: - resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==, tarball: https://registry.npmmirror.com/espree/-/espree-9.6.1.tgz} + espree@9.3.1: + resolution: {integrity: sha512-bvdyLmJMfwkV3NCRl5ZhJf22zBFo1y8bYh3VYb+bfzqNB4Je68P2sSuXyuFquzWLebHpNd2/d5uv7yoP9ISnGQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - acorn: 8.14.1 - acorn-jsx: 5.3.2(acorn@8.14.1) - eslint-visitor-keys: 3.4.3 - dev: true - /esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==, tarball: https://registry.npmmirror.com/esquery/-/esquery-1.6.0.tgz} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} - dependencies: - estraverse: 5.3.0 - dev: true - /esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==, tarball: https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz} + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} - dependencies: - estraverse: 5.3.0 - dev: true - /estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==, tarball: https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz} + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} - dev: true - /estree-walker@2.0.2: - resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==, tarball: https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - /estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==, tarball: https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz} - dependencies: - '@types/estree': 1.0.7 - dev: false + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - /esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==, tarball: https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - dev: true - /etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==, tarball: https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz} - engines: {node: '>= 0.6'} - dev: true + execa@9.5.2: + resolution: {integrity: sha512-EHlpxMCpHWSAh1dgS6bVeoLAXGnJNdR93aabr4QCGbzOM73o5XmRfM/e5FUqsw3aagP8S8XEWUWFAxnRBnAF0Q==} + engines: {node: ^18.19.0 || >=20.5.0} - /eventsource-parser@3.0.1: - resolution: {integrity: sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==, tarball: https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.0.1.tgz} - engines: {node: '>=18.0.0'} - dev: true + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} - /eventsource@3.0.7: - resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==, tarball: https://registry.npmmirror.com/eventsource/-/eventsource-3.0.7.tgz} - engines: {node: '>=18.0.0'} - dependencies: - eventsource-parser: 3.0.1 - dev: true + exsolve@1.1.0: + resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} - /execa@9.5.3: - resolution: {integrity: sha512-QFNnTvU3UjgWFy8Ef9iDHvIdcgZ344ebkwYx4/KLbR+CKQA4xBaHzv+iRpp86QfMHP8faFQLh8iOc57215y4Rg==, tarball: https://registry.npmmirror.com/execa/-/execa-9.5.3.tgz} - engines: {node: ^18.19.0 || >=20.5.0} - dependencies: - '@sindresorhus/merge-streams': 4.0.0 - cross-spawn: 7.0.6 - figures: 6.1.0 - get-stream: 9.0.1 - human-signals: 8.0.1 - is-plain-obj: 4.1.0 - is-stream: 4.0.1 - npm-run-path: 6.0.0 - pretty-ms: 9.2.0 - signal-exit: 4.1.0 - strip-final-newline: 4.0.0 - yoctocolors: 2.1.1 - dev: true + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} - /express-rate-limit@7.5.0(express@5.1.0): - resolution: {integrity: sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==, tarball: https://registry.npmmirror.com/express-rate-limit/-/express-rate-limit-7.5.0.tgz} - engines: {node: '>= 16'} - peerDependencies: - express: ^4.11 || 5 || ^5.0.0-beta.1 - dependencies: - express: 5.1.0 - dev: true - - /express@5.1.0: - resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==, tarball: https://registry.npmmirror.com/express/-/express-5.1.0.tgz} - engines: {node: '>= 18'} - dependencies: - accepts: 2.0.0 - body-parser: 2.2.0 - content-disposition: 1.0.0 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.2.2 - debug: 4.4.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 2.1.0 - fresh: 2.0.0 - http-errors: 2.0.0 - merge-descriptors: 2.0.0 - mime-types: 3.0.1 - on-finished: 2.4.1 - once: 1.4.0 - parseurl: 1.3.3 - proxy-addr: 2.0.7 - qs: 6.14.0 - range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.0 - serve-static: 2.2.0 - statuses: 2.0.1 - type-is: 2.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - dev: true + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - /exsolve@1.0.5: - resolution: {integrity: sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg==, tarball: https://registry.npmmirror.com/exsolve/-/exsolve-1.0.5.tgz} - dev: false + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} - /fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, tarball: https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz} - dev: true + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} - /fast-diff@1.3.0: - resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==, tarball: https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz} - dev: true + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - /fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==, tarball: https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz} - engines: {node: '>=8.6.0'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - dev: true + fast-json-stringify@7.0.1: + resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} - /fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==, tarball: https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz} - dev: true + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - /fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, tarball: https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz} - dev: true + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} - /fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==, tarball: https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz} - dependencies: - reusify: 1.1.0 - dev: true + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + + fast-uri@4.1.0: + resolution: {integrity: sha512-ZodJ2cRiLVWGi9IgPb3mbgSqM4CD3LexCHkuv0FfBXHJI1ADfucTD06m6clO2Cy5RZYsw/SiCVl/dyrFI/SYWA==} + + fastify-plugin@6.0.0: + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} + + fastify@5.10.0: + resolution: {integrity: sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==} - /fdir@6.4.4(picomatch@4.0.2): - resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==, tarball: https://registry.npmmirror.com/fdir/-/fdir-6.4.4.tgz} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} peerDependencies: picomatch: ^3 || ^4 peerDependenciesMeta: picomatch: optional: true - dependencies: - picomatch: 4.0.2 - /figures@6.1.0: - resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==, tarball: https://registry.npmmirror.com/figures/-/figures-6.1.0.tgz} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} - dependencies: - is-unicode-supported: 2.1.0 - dev: true - /file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==, tarball: https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - dependencies: - flat-cache: 4.0.1 - dev: true - /fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==, tarball: https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - dependencies: - to-regex-range: 5.0.1 - dev: true - /finalhandler@2.1.0: - resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==, tarball: https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.0.tgz} - engines: {node: '>= 0.8'} - dependencies: - debug: 4.4.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.1 - transitivePeerDependencies: - - supports-color - dev: true + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} - /find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==, tarball: https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - dev: true - /flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==, tarball: https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz} + flat-cache@4.0.0: + resolution: {integrity: sha512-EryKbCE/wxpxKniQlyas6PY1I9vwtF3uCBweX+N8KYTCn3Y12RTGtQAJ/bd5pl7kxUAc8v/R3Ake/N17OZiFqA==} engines: {node: '>=16'} - dependencies: - flatted: 3.3.3 - keyv: 4.5.4 - dev: true - /flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==, tarball: https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz} - dev: true + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - /follow-redirects@1.15.9: - resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==, tarball: https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.9.tgz} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' peerDependenciesMeta: debug: optional: true - dev: false - /form-data@4.0.2: - resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==, tarball: https://registry.npmmirror.com/form-data/-/form-data-4.0.2.tgz} - engines: {node: '>= 6'} - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - mime-types: 2.1.35 - dev: false + foreground-child@3.1.0: + resolution: {integrity: sha512-lXeSPRCndWPaipZbtI4CkvTZpF6OPsy19dkvf7+5AHeJD+w+iAKPc9Q78xWBmX4SdR+8xrtY9jTXs/YDv8q+Ug==} + engines: {node: '>=14'} - /forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==, tarball: https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz} - engines: {node: '>= 0.6'} - dev: true + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} - /fraction.js@4.3.7: - resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==, tarball: https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz} - dev: true + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - /fresh@2.0.0: - resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==, tarball: https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz} - engines: {node: '>= 0.8'} - dev: true + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - /fs-extra@11.3.0: - resolution: {integrity: sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==, tarball: https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.0.tgz} + fs-extra@11.3.6: + resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} engines: {node: '>=14.14'} - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.1.0 - universalify: 2.0.1 - dev: true - /fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, tarball: https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - requiresBuild: true - dev: true - optional: true - /function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==, tarball: https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz} + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - /gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==, tarball: https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz} + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} - dev: true - /get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==, tarball: https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - /get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==, tarball: https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - /get-stream@9.0.1: - resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==, tarball: https://registry.npmmirror.com/get-stream/-/get-stream-9.0.1.tgz} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} - dependencies: - '@sec-ant/readable-stream': 0.4.1 - is-stream: 4.0.1 - dev: true - /giget@2.0.0: - resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==, tarball: https://registry.npmmirror.com/giget/-/giget-2.0.0.tgz} + giget@3.3.0: + resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==} hasBin: true - dependencies: - citty: 0.1.6 - consola: 3.4.2 - defu: 6.1.4 - node-fetch-native: 1.6.6 - nypm: 0.6.0 - pathe: 2.0.3 - dev: false - /glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==, tarball: https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} - dependencies: - is-glob: 4.0.3 - dev: true - /glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==, tarball: https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - dependencies: - is-glob: 4.0.3 - dev: true - /globals@11.12.0: - resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==, tarball: https://registry.npmmirror.com/globals/-/globals-11.12.0.tgz} - engines: {node: '>=4'} - dev: true + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} - /globals@13.24.0: - resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==, tarball: https://registry.npmmirror.com/globals/-/globals-13.24.0.tgz} + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} engines: {node: '>=8'} - dependencies: - type-fest: 0.20.2 - dev: true - /globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==, tarball: https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz} + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} - dev: true - /gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==, tarball: https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - /graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, tarball: https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz} - dev: true + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - /has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==, tarball: https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - dev: true - /has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==, tarball: https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} - /has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==, tarball: https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - dependencies: - has-symbols: 1.1.0 - dev: false - /hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==, tarball: https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - dependencies: - function-bind: 1.1.2 - /hookable@5.5.3: - resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==, tarball: https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz} - dev: true + helmet@8.3.0: + resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==} + engines: {node: '>=18.0.0'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} + engines: {node: '>=8'} - /http-errors@2.0.0: - resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==, tarball: https://registry.npmmirror.com/http-errors/-/http-errors-2.0.0.tgz} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.1 - toidentifier: 1.0.1 - dev: true - /human-signals@8.0.1: - resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==, tarball: https://registry.npmmirror.com/human-signals/-/human-signals-8.0.1.tgz} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} - dev: true - /iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==, tarball: https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz} - engines: {node: '>=0.10.0'} - dependencies: - safer-buffer: 2.1.2 - dev: true + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - /ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, tarball: https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz} + ignore@5.2.0: + resolution: {integrity: sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==} engines: {node: '>= 4'} - dev: true - /ignore@7.0.4: - resolution: {integrity: sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==, tarball: https://registry.npmmirror.com/ignore/-/ignore-7.0.4.tgz} + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} - dev: false - /immutable@5.1.2: - resolution: {integrity: sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==, tarball: https://registry.npmmirror.com/immutable/-/immutable-5.1.2.tgz} - dev: true + immutable@5.1.9: + resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==} - /import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==, tarball: https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz} + import-fresh@3.2.1: + resolution: {integrity: sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==} engines: {node: '>=6'} - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - dev: true - /imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==, tarball: https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz} + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - dev: true - /inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==, tarball: https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz} - dev: true + 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==, tarball: https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz} - engines: {node: '>= 0.10'} - dev: true + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - /is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==, tarball: https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz} + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} - dependencies: - binary-extensions: 2.3.0 - dev: true - /is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==, tarball: https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} engines: {node: '>= 0.4'} - dependencies: - hasown: 2.0.2 - dev: true - /is-docker@3.0.0: - resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==, tarball: https://registry.npmmirror.com/is-docker/-/is-docker-3.0.0.tgz} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true - dev: true - /is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==, tarball: https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz} + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - dev: true - /is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==, tarball: https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - dependencies: - is-extglob: 2.1.1 - dev: true - /is-inside-container@1.0.0: - resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==, tarball: https://registry.npmmirror.com/is-inside-container/-/is-inside-container-1.0.0.tgz} + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} hasBin: true - dependencies: - is-docker: 3.0.0 - dev: true - /is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==, tarball: https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz} + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - dev: true - /is-plain-obj@4.1.0: - resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==, tarball: https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} - dev: true - - /is-promise@4.0.0: - resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==, tarball: https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz} - dev: true - /is-stream@4.0.1: - resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==, tarball: https://registry.npmmirror.com/is-stream/-/is-stream-4.0.1.tgz} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} - dev: true - /is-unicode-supported@2.1.0: - resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==, tarball: https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz} + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} - dev: true - /is-what@4.1.16: - resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==, tarball: https://registry.npmmirror.com/is-what/-/is-what-4.1.16.tgz} - engines: {node: '>=12.13'} - dev: true + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} - /is-wsl@3.1.0: - resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==, tarball: https://registry.npmmirror.com/is-wsl/-/is-wsl-3.1.0.tgz} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - dependencies: - is-inside-container: 1.0.0 - dev: true - /isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, tarball: https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz} - dev: true + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - /jiti@2.4.2: - resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==, tarball: https://registry.npmmirror.com/jiti/-/jiti-2.4.2.tgz} - hasBin: true - dev: false + jackspeak@3.1.2: + resolution: {integrity: sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==} + engines: {node: '>=14'} - /js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, tarball: https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz} - dev: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true - /js-tokens@9.0.1: - resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==, tarball: https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz} - dev: false + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - /js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==, tarball: https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true - dependencies: - argparse: 2.0.1 - dev: true - /jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==, tarball: https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz} + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} hasBin: true - dev: true - /json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==, tarball: https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz} - dev: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - /json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, tarball: https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz} - dev: true + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} - /json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==, tarball: https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz} - dev: true + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - /json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==, tarball: https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true - dev: true - /jsonfile@6.1.0: - resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==, tarball: https://registry.npmmirror.com/jsonfile/-/jsonfile-6.1.0.tgz} - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - dev: true + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} - /keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==, tarball: https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz} - dependencies: - json-buffer: 3.0.1 - dev: true + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - /klona@2.0.6: - resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==, tarball: https://registry.npmmirror.com/klona/-/klona-2.0.6.tgz} + klona@2.0.6: + resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} engines: {node: '>= 8'} - dev: false - /knitwork@1.2.0: - resolution: {integrity: sha512-xYSH7AvuQ6nXkq42x0v5S8/Iry+cfulBz/DJQzhIyESdLD7425jXsPy4vn5cCXU+HhRN2kVw51Vd1K6/By4BQg==, tarball: https://registry.npmmirror.com/knitwork/-/knitwork-1.2.0.tgz} - dev: false + knitwork@1.3.0: + resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} - /kolorist@1.8.0: - resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==, tarball: https://registry.npmmirror.com/kolorist/-/kolorist-1.8.0.tgz} - dev: true + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} - /levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==, tarball: https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz} + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - dev: true - /lilconfig@2.1.0: - resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==, tarball: https://registry.npmmirror.com/lilconfig/-/lilconfig-2.1.0.tgz} - engines: {node: '>=10'} - dev: true + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} - /local-pkg@1.1.1: - resolution: {integrity: sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==, tarball: https://registry.npmmirror.com/local-pkg/-/local-pkg-1.1.1.tgz} - engines: {node: '>=14'} - dependencies: - mlly: 1.7.4 - pkg-types: 2.1.0 - quansync: 0.2.10 - dev: false + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} - /locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==, tarball: https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - dependencies: - p-locate: 5.0.0 - dev: true - /lodash-es@4.17.21: - resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==, tarball: https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz} - dev: false + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} - /lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21): - resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==, tarball: https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz} + lodash-unified@1.0.3: + resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} peerDependencies: '@types/lodash-es': '*' lodash: '*' lodash-es: '*' - dependencies: - '@types/lodash-es': 4.17.12 - lodash: 4.17.21 - lodash-es: 4.17.21 - dev: false - /lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==, tarball: https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz} - dev: true + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - /lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==, tarball: https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - /lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==, tarball: https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz} - dependencies: - yallist: 3.1.1 - dev: true + lru-cache@10.2.0: + resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} + engines: {node: 14 || >=16.14} - /magic-string@0.30.17: - resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==, tarball: https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz} - dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} - /math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==, tarball: https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz} - engines: {node: '>= 0.4'} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - /media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==, tarball: https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz} - engines: {node: '>= 0.8'} - dev: true + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - /memoize-one@6.0.0: - resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==, tarball: https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz} - dev: false + magic-string@0.30.4: + resolution: {integrity: sha512-Q/TKtsC5BPm0kGqgBIF9oXAs/xEf2vRKiIB4wCRQTJOQIByZ1d+NnUOotvJOvNpi5RNIgVOMC3pOuaP1ZTDlVg==} + engines: {node: '>=12'} - /merge-descriptors@2.0.0: - resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==, tarball: https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz} - engines: {node: '>=18'} - dev: true + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} - /merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==, tarball: https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz} + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - dev: true - /micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==, tarball: https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz} + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - dev: true - /mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==, tarball: https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} - dev: false - /mime-db@1.54.0: - resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==, tarball: https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - dev: true - /mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==, tarball: https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz} - engines: {node: '>= 0.6'} - dependencies: - mime-db: 1.52.0 - dev: false + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true - /mime-types@3.0.1: - resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==, tarball: https://registry.npmmirror.com/mime-types/-/mime-types-3.0.1.tgz} - engines: {node: '>= 0.6'} - dependencies: - mime-db: 1.54.0 - dev: true + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} - /minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==, tarball: https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz} - dependencies: - brace-expansion: 1.1.11 - dev: true + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} - /minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==, tarball: https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz} - dev: true + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - /mitt@3.0.1: - resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==, tarball: https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz} - dev: true + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} - /mlly@1.7.4: - resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==, tarball: https://registry.npmmirror.com/mlly/-/mlly-1.7.4.tgz} - dependencies: - acorn: 8.14.1 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.1 - dev: false + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} - /mrmime@2.0.1: - resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==, tarball: https://registry.npmmirror.com/mrmime/-/mrmime-2.0.1.tgz} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} - dev: true - /ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, tarball: https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz} - dev: true + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - /nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==, tarball: https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - /nanoid@5.1.5: - resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==, tarball: https://registry.npmmirror.com/nanoid/-/nanoid-5.1.5.tgz} + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} engines: {node: ^18 || >=20} hasBin: true - dev: true - /natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==, tarball: https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz} - dev: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - /negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==, tarball: https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz} - engines: {node: '>= 0.6'} - dev: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - /node-addon-api@7.1.1: - resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==, tarball: https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz} - requiresBuild: true - dev: true - optional: true + node-abi@3.94.0: + resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} + engines: {node: '>=10'} - /node-fetch-native@1.6.6: - resolution: {integrity: sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==, tarball: https://registry.npmmirror.com/node-fetch-native/-/node-fetch-native-1.6.6.tgz} - dev: false + node-addon-api@7.0.0: + resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==} - /node-releases@2.0.19: - resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==, tarball: https://registry.npmmirror.com/node-releases/-/node-releases-2.0.19.tgz} - dev: true + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} - /normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==, tarball: https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz} - engines: {node: '>=0.10.0'} - dev: true + nodemailer@9.0.3: + resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} + engines: {node: '>=6.0.0'} - /normalize-range@0.1.2: - resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==, tarball: https://registry.npmmirror.com/normalize-range/-/normalize-range-0.1.2.tgz} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - dev: true - /normalize-wheel-es@1.2.0: - resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==, tarball: https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz} - dev: false + normalize-wheel-es@1.2.0: + resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} - /npm-run-path@6.0.0: - resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==, tarball: https://registry.npmmirror.com/npm-run-path/-/npm-run-path-6.0.0.tgz} + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} - dependencies: - path-key: 4.0.0 - unicorn-magic: 0.3.0 - dev: true - - /nth-check@2.1.1: - resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==, tarball: https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz} - dependencies: - boolbase: 1.0.0 - dev: true - - /nypm@0.6.0: - resolution: {integrity: sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg==, tarball: https://registry.npmmirror.com/nypm/-/nypm-0.6.0.tgz} - engines: {node: ^14.16.0 || >=16.10.0} - hasBin: true - dependencies: - citty: 0.1.6 - consola: 3.4.2 - pathe: 2.0.3 - pkg-types: 2.1.0 - tinyexec: 0.3.2 - dev: false - /object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, tarball: https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz} - engines: {node: '>=0.10.0'} - dev: true + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - /object-hash@3.0.0: - resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==, tarball: https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz} + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} - dev: true - - /object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==, tarball: https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz} - engines: {node: '>= 0.4'} - dev: true - /ohash@2.0.11: - resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==, tarball: https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz} - dev: false + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} - /on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==, tarball: https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz} - engines: {node: '>= 0.8'} - dependencies: - ee-first: 1.1.1 - dev: true + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} - /once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==, tarball: https://registry.npmmirror.com/once/-/once-1.4.0.tgz} - dependencies: - wrappy: 1.0.2 - dev: true + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - /open@10.1.2: - resolution: {integrity: sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==, tarball: https://registry.npmmirror.com/open/-/open-10.1.2.tgz} + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} - dependencies: - default-browser: 5.2.1 - define-lazy-prop: 3.0.0 - is-inside-container: 1.0.0 - is-wsl: 3.1.0 - dev: true - /optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==, tarball: https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - dev: true - /p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==, tarball: https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz} + p-limit@3.0.2: + resolution: {integrity: sha512-iwqZSOoWIW+Ew4kAGUlN16J4M7OB3ysMLSZtnhmqx7njIHFPlxWBX8xo3lVTyFVq6mI/lL9qt2IsN1sHwaxJkg==} engines: {node: '>=10'} - dependencies: - yocto-queue: 0.1.0 - dev: true - /p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==, tarball: https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz} + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - dependencies: - p-limit: 3.1.0 - dev: true - /parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==, tarball: https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - dependencies: - callsites: 3.1.0 - dev: true - /parse-ms@4.0.0: - resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==, tarball: https://registry.npmmirror.com/parse-ms/-/parse-ms-4.0.0.tgz} - engines: {node: '>=18'} - dev: true + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - /parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==, tarball: https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz} - engines: {node: '>= 0.8'} - dev: true + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} - /path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, tarball: https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - dev: true - /path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==, tarball: https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - dev: true - /path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==, tarball: https://registry.npmmirror.com/path-key/-/path-key-4.0.0.tgz} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} - dev: true - /path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==, tarball: https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz} - dev: true + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - /path-to-regexp@8.2.0: - resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==, tarball: https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz} - engines: {node: '>=16'} - dev: true + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} - /pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, tarball: https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} - /perfect-debounce@1.0.0: - resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==, tarball: https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - /picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, tarball: https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} - /picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==, tarball: https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz} + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - dev: true - /picomatch@4.0.2: - resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==, tarball: https://registry.npmmirror.com/picomatch/-/picomatch-4.0.2.tgz} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} - /pinia-plugin-persistedstate@4.3.0(pinia@2.3.1): - resolution: {integrity: sha512-x9wxpHj6iFDj5ITQJ3rj6+KesEqyRk/vqcE3WE+VGfetleV9Zufqwa9qJ6AkA5wmRSQEp7BTA1us/MDVTRHFFw==, tarball: https://registry.npmmirror.com/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-4.3.0.tgz} + pinia-plugin-persistedstate@4.2.0: + resolution: {integrity: sha512-3buhA7ac+ssbOIx3VRCC8oHkoFwhDM9oHRCjo7nj+O8WUqnW+jRqh7eYT5eS/DNa3H28zp3dYf/nd/Vc8zj8eQ==} peerDependencies: - '@pinia/nuxt': '>=0.10.0' - pinia: '>=3.0.0' + '@pinia/nuxt': '>=0.9.0' + pinia: '>=2.3.0' peerDependenciesMeta: '@pinia/nuxt': optional: true pinia: optional: true - dependencies: - '@nuxt/kit': 3.17.2 - deep-pick-omit: 1.2.1 - defu: 6.1.4 - destr: 2.0.5 - pinia: 2.3.1(vue@3.5.13) - transitivePeerDependencies: - - magicast - dev: false - /pinia@2.3.1(vue@3.5.13): - resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==, tarball: https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz} + pinia@2.3.1: + resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==} peerDependencies: typescript: '>=4.4.4' vue: ^2.7.0 || ^3.5.11 peerDependenciesMeta: typescript: optional: true - dependencies: - '@vue/devtools-api': 6.6.4 - vue: 3.5.13 - vue-demi: 0.14.10(vue@3.5.13) - transitivePeerDependencies: - - '@vue/composition-api' - dev: false - /pkce-challenge@5.0.0: - resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==, tarball: https://registry.npmmirror.com/pkce-challenge/-/pkce-challenge-5.0.0.tgz} - engines: {node: '>=16.20.0'} - dev: true + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} - /pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==, tarball: https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz} - dependencies: - confbox: 0.1.8 - mlly: 1.7.4 - pathe: 2.0.3 - dev: false + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} - /pkg-types@2.1.0: - resolution: {integrity: sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A==, tarball: https://registry.npmmirror.com/pkg-types/-/pkg-types-2.1.0.tgz} - dependencies: - confbox: 0.2.2 - exsolve: 1.0.5 - pathe: 2.0.3 - dev: false + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - /postcss-js@4.0.1(postcss@8.5.3): - resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==, tarball: https://registry.npmmirror.com/postcss-js/-/postcss-js-4.0.1.tgz} + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + postcss-js@4.0.0: + resolution: {integrity: sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==} engines: {node: ^12 || ^14 || >= 16} peerDependencies: - postcss: ^8.4.21 - dependencies: - camelcase-css: 2.0.1 - postcss: 8.5.3 - dev: true + postcss: ^8.3.3 - /postcss-load-config@3.1.4(postcss@8.5.3): - resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==, tarball: https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-3.1.4.tgz} + postcss-load-config@3.1.4: + resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} engines: {node: '>= 10'} peerDependencies: postcss: '>=8.0.9' @@ -3174,608 +2140,384 @@ packages: optional: true ts-node: optional: true - dependencies: - lilconfig: 2.1.0 - postcss: 8.5.3 - yaml: 1.10.2 - dev: true - /postcss-nested@5.0.6(postcss@8.5.3): - resolution: {integrity: sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==, tarball: https://registry.npmmirror.com/postcss-nested/-/postcss-nested-5.0.6.tgz} + postcss-nested@5.0.6: + resolution: {integrity: sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==} engines: {node: '>=12.0'} peerDependencies: postcss: ^8.2.14 - dependencies: - postcss: 8.5.3 - postcss-selector-parser: 6.1.2 - dev: true - /postcss-selector-parser@6.1.2: - resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==, tarball: https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz} + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} engines: {node: '>=4'} - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - dev: true - /postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==, tarball: https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz} - dev: true + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - /postcss@8.5.3: - resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==, tarball: https://registry.npmmirror.com/postcss/-/postcss-8.5.3.tgz} + postcss@8.5.19: + resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} engines: {node: ^10 || ^12 || >=14} - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - /prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==, tarball: https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - dev: true - /prettier-linter-helpers@1.0.0: - resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==, tarball: https://registry.npmmirror.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz} + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} engines: {node: '>=6.0.0'} - dependencies: - fast-diff: 1.3.0 - dev: true - /prettier@3.5.3: - resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==, tarball: https://registry.npmmirror.com/prettier/-/prettier-3.5.3.tgz} + prettier@3.9.5: + resolution: {integrity: sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==} engines: {node: '>=14'} hasBin: true - dev: true - /pretty-ms@9.2.0: - resolution: {integrity: sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==, tarball: https://registry.npmmirror.com/pretty-ms/-/pretty-ms-9.2.0.tgz} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} - dependencies: - parse-ms: 4.0.0 - dev: true - /proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==, tarball: https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz} - engines: {node: '>= 0.10'} - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - dev: true + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} - /proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==, tarball: https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz} - dev: false + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} - /punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==, tarball: https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz} - engines: {node: '>=6'} - dev: true + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} - /qs@6.14.0: - resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==, tarball: https://registry.npmmirror.com/qs/-/qs-6.14.0.tgz} - engines: {node: '>=0.6'} - dependencies: - side-channel: 1.1.0 - dev: true + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} - /quansync@0.2.10: - resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==, tarball: https://registry.npmmirror.com/quansync/-/quansync-0.2.10.tgz} - dev: false + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - /queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==, tarball: https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz} - dev: true + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - /quick-lru@5.1.1: - resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==, tarball: https://registry.npmmirror.com/quick-lru/-/quick-lru-5.1.1.tgz} + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} - dev: true - /range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==, tarball: https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz} - engines: {node: '>= 0.6'} - dev: true + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} - /raw-body@3.0.0: - resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==, tarball: https://registry.npmmirror.com/raw-body/-/raw-body-3.0.0.tgz} - engines: {node: '>= 0.8'} - dependencies: - bytes: 3.1.2 - http-errors: 2.0.0 - iconv-lite: 0.6.3 - unpipe: 1.0.0 - dev: true + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true - /rc9@2.1.2: - resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==, tarball: https://registry.npmmirror.com/rc9/-/rc9-2.1.2.tgz} - dependencies: - defu: 6.1.4 - destr: 2.0.5 - dev: false + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} - /readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==, tarball: https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz} + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} - dependencies: - picomatch: 2.3.1 - dev: true - /readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==, tarball: https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz} - engines: {node: '>= 14.18.0'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} - /resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==, tarball: https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz} + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - dev: true - /resolve@1.22.10: - resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==, tarball: https://registry.npmmirror.com/resolve/-/resolve-1.22.10.tgz} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} hasBin: true - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - dev: true - /reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==, tarball: https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - dev: true - /rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==, tarball: https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz} - dev: true + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - /rollup@4.40.2: - resolution: {integrity: sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==, tarball: https://registry.npmmirror.com/rollup/-/rollup-4.40.2.tgz} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} + rimraf@5.0.5: + resolution: {integrity: sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==} + engines: {node: '>=14'} hasBin: true - dependencies: - '@types/estree': 1.0.7 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.40.2 - '@rollup/rollup-android-arm64': 4.40.2 - '@rollup/rollup-darwin-arm64': 4.40.2 - '@rollup/rollup-darwin-x64': 4.40.2 - '@rollup/rollup-freebsd-arm64': 4.40.2 - '@rollup/rollup-freebsd-x64': 4.40.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.40.2 - '@rollup/rollup-linux-arm-musleabihf': 4.40.2 - '@rollup/rollup-linux-arm64-gnu': 4.40.2 - '@rollup/rollup-linux-arm64-musl': 4.40.2 - '@rollup/rollup-linux-loongarch64-gnu': 4.40.2 - '@rollup/rollup-linux-powerpc64le-gnu': 4.40.2 - '@rollup/rollup-linux-riscv64-gnu': 4.40.2 - '@rollup/rollup-linux-riscv64-musl': 4.40.2 - '@rollup/rollup-linux-s390x-gnu': 4.40.2 - '@rollup/rollup-linux-x64-gnu': 4.40.2 - '@rollup/rollup-linux-x64-musl': 4.40.2 - '@rollup/rollup-win32-arm64-msvc': 4.40.2 - '@rollup/rollup-win32-ia32-msvc': 4.40.2 - '@rollup/rollup-win32-x64-msvc': 4.40.2 - fsevents: 2.3.3 - dev: true - /router@2.2.0: - resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==, tarball: https://registry.npmmirror.com/router/-/router-2.2.0.tgz} - engines: {node: '>= 18'} - dependencies: - debug: 4.4.0 - depd: 2.0.0 - is-promise: 4.0.0 - parseurl: 1.3.3 - path-to-regexp: 8.2.0 - transitivePeerDependencies: - - supports-color - dev: true + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true - /run-applescript@7.0.0: - resolution: {integrity: sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==, tarball: https://registry.npmmirror.com/run-applescript/-/run-applescript-7.0.0.tgz} + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} - dev: true - /run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==, tarball: https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz} - dependencies: - queue-microtask: 1.2.3 - dev: true + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - /safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==, tarball: https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz} - dev: true + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - /safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, tarball: https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz} - dev: true + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - /sass@1.88.0: - resolution: {integrity: sha512-sF6TWQqjFvr4JILXzG4ucGOLELkESHL+I5QJhh7CNaE+Yge0SI+ehCatsXhJ7ymU1hAFcIS3/PBpjdIbXoyVbg==, tarball: https://registry.npmmirror.com/sass/-/sass-1.88.0.tgz} - engines: {node: '>=14.0.0'} + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} hasBin: true - dependencies: - chokidar: 4.0.3 - immutable: 5.1.2 - source-map-js: 1.2.1 - optionalDependencies: - '@parcel/watcher': 2.5.1 - dev: true - /scule@1.3.0: - resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==, tarball: https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz} - dev: false + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} - /semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==, tarball: https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz} + sass@1.101.0: + resolution: {integrity: sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==} + engines: {node: '>=20.19.0'} hasBin: true - dev: true - /semver@7.7.1: - resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==, tarball: https://registry.npmmirror.com/semver/-/semver-7.7.1.tgz} - engines: {node: '>=10'} + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - /send@1.2.0: - resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==, tarball: https://registry.npmmirror.com/send/-/send-1.2.0.tgz} - engines: {node: '>= 18'} - dependencies: - debug: 4.4.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 2.0.0 - http-errors: 2.0.0 - mime-types: 3.0.1 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.1 - transitivePeerDependencies: - - supports-color - dev: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true - /serve-static@2.2.0: - resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==, tarball: https://registry.npmmirror.com/serve-static/-/serve-static-2.2.0.tgz} - engines: {node: '>= 18'} - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 1.2.0 - transitivePeerDependencies: - - supports-color - dev: true + set-cookie-parser@2.6.0: + resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} - /setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==, tarball: https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz} - dev: true + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - /shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==, tarball: https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} - dependencies: - shebang-regex: 3.0.0 - dev: true - /shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, tarball: https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz} + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - dev: true - /side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==, tarball: https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz} + shell-quote@1.8.4: + resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} engines: {node: '>= 0.4'} - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - dev: true - /side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==, tarball: https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz} - engines: {node: '>= 0.4'} - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - dev: true + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} - /side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==, tarball: https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz} - engines: {node: '>= 0.4'} - 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 - dev: true + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - /side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==, tarball: https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz} - engines: {node: '>= 0.4'} - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - dev: true - - /signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==, tarball: https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz} - engines: {node: '>=14'} - dev: true + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - /sirv@3.0.1: - resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==, tarball: https://registry.npmmirror.com/sirv/-/sirv-3.0.1.tgz} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} - dependencies: - '@polka/url': 1.0.0-next.29 - mrmime: 2.0.1 - totalist: 3.0.1 - dev: true - /source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, tarball: https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - /speakingurl@14.0.1: - resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==, tarball: https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz} + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} - dev: true - /statuses@2.0.1: - resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==, tarball: https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - dev: true - /std-env@3.9.0: - resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==, tarball: https://registry.npmmirror.com/std-env/-/std-env-3.9.0.tgz} - dev: false + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + + string-width@4.2.0: + resolution: {integrity: sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} - /strip-final-newline@4.0.0: - resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==, tarball: https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-4.0.0.tgz} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} - dev: true - /strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==, tarball: https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - dev: true - /strip-literal@3.0.0: - resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==, tarball: https://registry.npmmirror.com/strip-literal/-/strip-literal-3.0.0.tgz} - dependencies: - js-tokens: 9.0.1 - dev: false + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} - /superjson@2.2.2: - resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==, tarball: https://registry.npmmirror.com/superjson/-/superjson-2.2.2.tgz} + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} - dependencies: - copy-anything: 3.0.5 - dev: true - /supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==, tarball: https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - dependencies: - has-flag: 4.0.0 - dev: true - /supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==, tarball: https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - dev: true - /synckit@0.11.4: - resolution: {integrity: sha512-Q/XQKRaJiLiFIBNN+mndW7S/RHxvwzuZS6ZwmRzUBqJBv/5QIKCEwkBC8GBf8EQJKYnaFs0wOZbKTXBPj8L9oQ==, tarball: https://registry.npmmirror.com/synckit/-/synckit-0.11.4.tgz} + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + synckit@0.11.13: + resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} engines: {node: ^14.18.0 || >=16.0.0} - dependencies: - '@pkgr/core': 0.2.4 - tslib: 2.8.1 - dev: true - /tailwindcss@3.0.24(postcss@8.5.3): - resolution: {integrity: sha512-H3uMmZNWzG6aqmg9q07ZIRNIawoiEcNFKDfL+YzOPuPsXuDXxJxB9icqzLgdzKNwjG3SAro2h9SYav8ewXNgig==, tarball: https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.0.24.tgz} + tailwindcss@3.0.24: + resolution: {integrity: sha512-H3uMmZNWzG6aqmg9q07ZIRNIawoiEcNFKDfL+YzOPuPsXuDXxJxB9icqzLgdzKNwjG3SAro2h9SYav8ewXNgig==} engines: {node: '>=12.13.0'} hasBin: true peerDependencies: postcss: ^8.0.9 - dependencies: - arg: 5.0.2 - chokidar: 3.6.0 - color-name: 1.1.4 - detective: 5.2.1 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.3.3 - glob-parent: 6.0.2 - is-glob: 4.0.3 - lilconfig: 2.1.0 - normalize-path: 3.0.0 - object-hash: 3.0.0 - picocolors: 1.1.1 - postcss: 8.5.3 - postcss-js: 4.0.1(postcss@8.5.3) - postcss-load-config: 3.1.4(postcss@8.5.3) - postcss-nested: 5.0.6(postcss@8.5.3) - postcss-selector-parser: 6.1.2 - postcss-value-parser: 4.2.0 - quick-lru: 5.1.1 - resolve: 1.22.10 - transitivePeerDependencies: - - ts-node - dev: true - /tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==, tarball: https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz} - dev: false + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.1.4: + resolution: {integrity: sha512-o3pS2zlG4gxr67GmFYBLlq+dM8gyRGUOvsrHclSkvtVtQbjV0s/+ZE8OpICbaj8clrX3tjeHngYGP7rweaBnuw==} + engines: {node: '>=6'} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} - /tinyglobby@0.2.13: - resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==, tarball: https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.13.tgz} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - dependencies: - fdir: 6.4.4(picomatch@4.0.2) - picomatch: 4.0.2 - /to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==, tarball: https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - dependencies: - is-number: 7.0.0 - dev: true - /toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==, tarball: https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz} + toad-cache@3.7.4: + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} + engines: {node: '>=20'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - dev: true - /totalist@3.0.1: - resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==, tarball: https://registry.npmmirror.com/totalist/-/totalist-3.0.1.tgz} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} - dev: true - /tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, tarball: https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz} - dev: true + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - /type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==, tarball: https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - dependencies: - prelude-ls: 1.2.1 - dev: true - /type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==, tarball: https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz} + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} - dev: true - - /type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==, tarball: https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz} - engines: {node: '>= 0.6'} - dependencies: - content-type: 1.0.5 - media-typer: 1.1.0 - mime-types: 3.0.1 - dev: true - /ufo@1.6.1: - resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==, tarball: https://registry.npmmirror.com/ufo/-/ufo-1.6.1.tgz} - dev: false + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} - /unctx@2.4.1: - resolution: {integrity: sha512-AbaYw0Nm4mK4qjhns67C+kgxR2YWiwlDBPzxrN8h8C6VtAdCgditAY5Dezu3IJy4XVqAnbrXt9oQJvsn3fyozg==, tarball: https://registry.npmmirror.com/unctx/-/unctx-2.4.1.tgz} - dependencies: - acorn: 8.14.1 - estree-walker: 3.0.3 - magic-string: 0.30.17 - unplugin: 2.3.3 - dev: false + unctx@2.5.0: + resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==} - /unicorn-magic@0.3.0: - resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==, tarball: https://registry.npmmirror.com/unicorn-magic/-/unicorn-magic-0.3.0.tgz} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} - dev: true - /unimport@5.0.1: - resolution: {integrity: sha512-1YWzPj6wYhtwHE+9LxRlyqP4DiRrhGfJxdtH475im8ktyZXO3jHj/3PZ97zDdvkYoovFdi0K4SKl3a7l92v3sQ==, tarball: https://registry.npmmirror.com/unimport/-/unimport-5.0.1.tgz} - engines: {node: '>=18.12.0'} - dependencies: - acorn: 8.14.1 - escape-string-regexp: 5.0.0 - estree-walker: 3.0.3 - local-pkg: 1.1.1 - magic-string: 0.30.17 - mlly: 1.7.4 - pathe: 2.0.3 - picomatch: 4.0.2 - pkg-types: 2.1.0 - scule: 1.3.0 - strip-literal: 3.0.0 - tinyglobby: 0.2.13 - unplugin: 2.3.3 - unplugin-utils: 0.2.4 - dev: false - - /universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==, tarball: https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - dev: true - - /unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==, tarball: https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz} - engines: {node: '>= 0.8'} - dev: true - - /unplugin-utils@0.2.4: - resolution: {integrity: sha512-8U/MtpkPkkk3Atewj1+RcKIjb5WBimZ/WSLhhR3w6SsIj8XJuKTacSP8g+2JhfSGw0Cb125Y+2zA/IzJZDVbhA==, tarball: https://registry.npmmirror.com/unplugin-utils/-/unplugin-utils-0.2.4.tgz} - engines: {node: '>=18.12.0'} - dependencies: - pathe: 2.0.3 - picomatch: 4.0.2 - dev: false - /unplugin@2.3.3: - resolution: {integrity: sha512-DN4DgiS13HFrAapoRmmoa9B35QzmQVRH2k58HelO28htXVNEEFZ8CGlZL0aRHXOXtz9McwY6lqaZjcc15uWMow==, tarball: https://registry.npmmirror.com/unplugin/-/unplugin-2.3.3.tgz} + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} - dependencies: - acorn: 8.14.1 - picomatch: 4.0.2 - webpack-virtual-modules: 0.6.2 - dev: false - /untyped@2.0.0: - resolution: {integrity: sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==, tarball: https://registry.npmmirror.com/untyped/-/untyped-2.0.0.tgz} + untyped@2.0.0: + resolution: {integrity: sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==} hasBin: true - dependencies: - citty: 0.1.6 - defu: 6.1.4 - jiti: 2.4.2 - knitwork: 1.2.0 - scule: 1.3.0 - dev: false - /update-browserslist-db@1.1.3(browserslist@4.24.5): - resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==, tarball: https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' - dependencies: - browserslist: 4.24.5 - escalade: 3.2.0 - picocolors: 1.1.1 - dev: true - - /uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==, tarball: https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz} - dependencies: - punycode: 2.3.1 - dev: true - /util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, tarball: https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz} - dev: true + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - /vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==, tarball: https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz} - engines: {node: '>= 0.8'} - dev: true + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - /vite-hot-client@2.0.4(vite@6.3.5): - resolution: {integrity: sha512-W9LOGAyGMrbGArYJN4LBCdOC5+Zwh7dHvOHC0KmGKkJhsOzaKbpo/jEjpPKVHIW0/jBWj8RZG0NUxfgA8BxgAg==, tarball: https://registry.npmmirror.com/vite-hot-client/-/vite-hot-client-2.0.4.tgz} + vite-hot-client@2.2.0: + resolution: {integrity: sha512-76Zs9zrHbH7M7wqeyooGQKdX+yg0pQ0xuQ1PbFp4z5a0Lzn2e5IPFoCswnmqZ4GiwqB4Jo3WcDAMO9jARTJl8w==} peerDependencies: - vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 - dependencies: - vite: 6.3.5(sass@1.88.0)(yaml@2.7.1) - dev: true + vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0 - /vite-plugin-inspect@0.8.9(vite@6.3.5): - resolution: {integrity: sha512-22/8qn+LYonzibb1VeFZmISdVao5kC22jmEKm24vfFE8siEn47EpVcCLYMv6iKOYMJfjSvSJfueOwcFCkUnV3A==, tarball: https://registry.npmmirror.com/vite-plugin-inspect/-/vite-plugin-inspect-0.8.9.tgz} + vite-plugin-inspect@0.8.9: + resolution: {integrity: sha512-22/8qn+LYonzibb1VeFZmISdVao5kC22jmEKm24vfFE8siEn47EpVcCLYMv6iKOYMJfjSvSJfueOwcFCkUnV3A==} engines: {node: '>=14'} peerDependencies: '@nuxt/kit': '*' @@ -3783,64 +2525,20 @@ packages: peerDependenciesMeta: '@nuxt/kit': optional: true - dependencies: - '@antfu/utils': 0.7.10 - '@rollup/pluginutils': 5.1.4 - debug: 4.4.0 - error-stack-parser-es: 0.1.5 - fs-extra: 11.3.0 - open: 10.1.2 - perfect-debounce: 1.0.0 - picocolors: 1.1.1 - sirv: 3.0.1 - vite: 6.3.5(sass@1.88.0)(yaml@2.7.1) - transitivePeerDependencies: - - rollup - - supports-color - dev: true - /vite-plugin-vue-devtools@7.7.6(vite@6.3.5)(vue@3.5.13): - resolution: {integrity: sha512-L7nPVM5a7lgit/Z+36iwoqHOaP3wxqVi1UvaDJwGCfblS9Y6vNqf32ILlzJVH9c47aHu90BhDXeZc+rgzHRHcw==, tarball: https://registry.npmmirror.com/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-7.7.6.tgz} + vite-plugin-vue-devtools@7.7.10: + resolution: {integrity: sha512-aHkUvDKxUdmSnuolEc8DkOrSjMpKO4Bb/BG01x2pBv6xTv487No9Tr7BMAsH2XWwMd+LtlUeCamhEZyEu9vxKQ==} engines: {node: '>=v14.21.3'} peerDependencies: - vite: ^3.1.0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 - dependencies: - '@vue/devtools-core': 7.7.6(vite@6.3.5)(vue@3.5.13) - '@vue/devtools-kit': 7.7.6 - '@vue/devtools-shared': 7.7.6 - execa: 9.5.3 - sirv: 3.0.1 - vite: 6.3.5(sass@1.88.0)(yaml@2.7.1) - vite-plugin-inspect: 0.8.9(vite@6.3.5) - vite-plugin-vue-inspector: 5.3.1(vite@6.3.5) - transitivePeerDependencies: - - '@nuxt/kit' - - rollup - - supports-color - - vue - dev: true + vite: ^3.1.0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 - /vite-plugin-vue-inspector@5.3.1(vite@6.3.5): - resolution: {integrity: sha512-cBk172kZKTdvGpJuzCCLg8lJ909wopwsu3Ve9FsL1XsnLBiRT9U3MePcqrgGHgCX2ZgkqZmAGR8taxw+TV6s7A==, tarball: https://registry.npmmirror.com/vite-plugin-vue-inspector/-/vite-plugin-vue-inspector-5.3.1.tgz} + vite-plugin-vue-inspector@5.4.0: + resolution: {integrity: sha512-Iq/024CydcE46FZqWPU4t4lw4uYOdLnFSO1RNxJVt2qY9zxIjmnkBqhHnYaReWM82kmNnaXs7OkfgRrV2GEjyw==} peerDependencies: - vite: ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 - dependencies: - '@babel/core': 7.27.1 - '@babel/plugin-proposal-decorators': 7.27.1(@babel/core@7.27.1) - '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.27.1) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.27.1) - '@babel/plugin-transform-typescript': 7.27.1(@babel/core@7.27.1) - '@vue/babel-plugin-jsx': 1.4.0(@babel/core@7.27.1) - '@vue/compiler-dom': 3.5.13 - kolorist: 1.8.0 - magic-string: 0.30.17 - vite: 6.3.5(sass@1.88.0)(yaml@2.7.1) - transitivePeerDependencies: - - supports-color - dev: true + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - /vite@6.3.5(sass@1.88.0)(yaml@2.7.1): - resolution: {integrity: sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==, tarball: https://registry.npmmirror.com/vite/-/vite-6.3.5.tgz} + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: @@ -3878,151 +2576,2659 @@ packages: optional: true yaml: optional: true - dependencies: - esbuild: 0.25.4 - fdir: 6.4.4(picomatch@4.0.2) - picomatch: 4.0.2 - postcss: 8.5.3 - rollup: 4.40.2 - sass: 1.88.0 - tinyglobby: 0.2.13 - yaml: 2.7.1 - optionalDependencies: - fsevents: 2.3.3 - dev: true - /vue-demi@0.14.10(vue@3.5.13): - resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==, tarball: https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz} + vue-component-type-helpers@3.3.7: + resolution: {integrity: sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} engines: {node: '>=12'} hasBin: true - requiresBuild: true peerDependencies: '@vue/composition-api': ^1.0.0-rc.1 vue: ^3.0.0-0 || ^2.6.0 peerDependenciesMeta: '@vue/composition-api': optional: true - dependencies: - vue: 3.5.13 - dev: false - /vue-eslint-parser@9.4.3(eslint@9.26.0): - resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==, tarball: https://registry.npmmirror.com/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz} + vue-eslint-parser@9.4.3: + resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==} engines: {node: ^14.17.0 || >=16.0.0} peerDependencies: eslint: '>=6.0.0' - dependencies: - debug: 4.4.0 - eslint: 9.26.0 - eslint-scope: 7.2.2 - eslint-visitor-keys: 3.4.3 - espree: 9.6.1 - esquery: 1.6.0 - lodash: 4.17.21 - semver: 7.7.1 - transitivePeerDependencies: - - supports-color - dev: true - /vue-i18n@11.0.0-rc.1(vue@3.5.13): - resolution: {integrity: sha512-qbdCbA537HEdr2yXQ4ec/OMDsoHjod1DwnWbrf+l4Cu/O7CYTCKsOyITUm3RmrCJgRnoVycuR6i/JWdNTJvD5g==, tarball: https://registry.npmmirror.com/vue-i18n/-/vue-i18n-11.0.0-rc.1.tgz} - engines: {node: '>= 16'} + vue-i18n@11.4.6: + resolution: {integrity: sha512-l0gE7Rfy0phCa5ChKYkOq543Wgd39BCK6hkktfr1Ed4D99oRkgPK9ffShASZdeC8OJxGfdWmpYoAaAH6iLEuIg==} + engines: {node: '>= 22'} peerDependencies: vue: ^3.0.0 - dependencies: - '@intlify/core-base': 11.0.0-rc.1 - '@intlify/shared': 11.0.0-rc.1 - '@vue/devtools-api': 6.6.4 - vue: 3.5.13 - dev: false - /vue-router@4.5.1(vue@3.5.13): - resolution: {integrity: sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==, tarball: https://registry.npmmirror.com/vue-router/-/vue-router-4.5.1.tgz} + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} peerDependencies: - vue: ^3.2.0 - dependencies: - '@vue/devtools-api': 6.6.4 - vue: 3.5.13 - dev: false + vue: ^3.5.0 - /vue@3.5.13: - resolution: {integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==, tarball: https://registry.npmmirror.com/vue/-/vue-3.5.13.tgz} + vue@3.5.40: + resolution: {integrity: sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==} peerDependencies: typescript: '*' peerDependenciesMeta: typescript: optional: true - dependencies: - '@vue/compiler-dom': 3.5.13 - '@vue/compiler-sfc': 3.5.13 - '@vue/runtime-dom': 3.5.13 - '@vue/server-renderer': 3.5.13(vue@3.5.13) - '@vue/shared': 3.5.13 - /webpack-virtual-modules@0.6.2: - resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==, tarball: https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz} - dev: false + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} - /which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==, tarball: https://registry.npmmirror.com/which/-/which-2.0.2.tgz} + which@2.0.1: + resolution: {integrity: sha512-N7GBZOTswtB9lkQBZA4+zAXrjEIWAUOB93AvzUiudRzRxhUdLURQ7D/gAIMY1gatT/LTbmbcv8SiYazy3eYB7w==} engines: {node: '>= 8'} hasBin: true - dependencies: - isexe: 2.0.0 - dev: true - /word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==, tarball: https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz} + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - dev: true - /wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, tarball: https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz} - dev: true + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrap-ansi@9.0.0: + resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} - /xml-name-validator@4.0.0: - resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==, tarball: https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz} + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} - dev: true - /xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==, tarball: https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} - dev: true - /yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==, tarball: https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz} - dev: true + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - /yaml@1.10.2: - resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==, tarball: https://registry.npmmirror.com/yaml/-/yaml-1.10.2.tgz} + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} - dev: true - /yaml@2.7.1: - resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==, tarball: https://registry.npmmirror.com/yaml/-/yaml-2.7.1.tgz} - engines: {node: '>= 14'} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} hasBin: true - dev: true - /yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==, tarball: https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz} - engines: {node: '>=10'} - dev: true + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} - /yoctocolors@2.1.1: - resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==, tarball: https://registry.npmmirror.com/yoctocolors/-/yoctocolors-2.1.1.tgz} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - dev: true - /zod-to-json-schema@3.24.5(zod@3.24.4): - resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==, tarball: https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz} - peerDependencies: - zod: ^3.24.1 + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@antfu/utils@0.7.10': {} + + '@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.12 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.6 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@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-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@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-proposal-decorators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-syntax-decorators@7.29.7(@babel/core@7.29.7)': dependencies: - zod: 3.24.4 - dev: true + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@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 + + '@ctrl/tinycolor@4.2.0': {} + + '@element-plus/icons-vue@2.3.2(vue@3.5.40)': + dependencies: + vue: 3.5.40 + + '@esbuild/aix-ppc64@0.25.0': + optional: true + + '@esbuild/android-arm64@0.25.0': + optional: true + + '@esbuild/android-arm@0.25.0': + optional: true + + '@esbuild/android-x64@0.25.0': + optional: true + + '@esbuild/darwin-arm64@0.25.0': + optional: true + + '@esbuild/darwin-x64@0.25.0': + optional: true + + '@esbuild/freebsd-arm64@0.25.0': + optional: true + + '@esbuild/freebsd-x64@0.25.0': + optional: true + + '@esbuild/linux-arm64@0.25.0': + optional: true + + '@esbuild/linux-arm@0.25.0': + optional: true + + '@esbuild/linux-ia32@0.25.0': + optional: true + + '@esbuild/linux-loong64@0.25.0': + optional: true + + '@esbuild/linux-mips64el@0.25.0': + optional: true + + '@esbuild/linux-ppc64@0.25.0': + optional: true + + '@esbuild/linux-riscv64@0.25.0': + optional: true + + '@esbuild/linux-s390x@0.25.0': + optional: true + + '@esbuild/linux-x64@0.25.0': + optional: true + + '@esbuild/netbsd-arm64@0.25.0': + optional: true + + '@esbuild/netbsd-x64@0.25.0': + optional: true + + '@esbuild/openbsd-arm64@0.25.0': + optional: true + + '@esbuild/openbsd-x64@0.25.0': + optional: true + + '@esbuild/sunos-x64@0.25.0': + optional: true + + '@esbuild/win32-arm64@0.25.0': + optional: true + + '@esbuild/win32-ia32@0.25.0': + optional: true + + '@esbuild/win32-x64@0.25.0': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.7.0))': + dependencies: + eslint: 9.39.5(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.2.0 + import-fresh: 3.2.1 + js-yaml: 4.3.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@fastify/accept-negotiator@2.0.1': {} + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.3 + + '@fastify/busboy@3.2.0': {} + + '@fastify/cookie@11.1.2': + dependencies: + cookie: 2.0.1 + fastify-plugin: 6.0.0 + + '@fastify/deepmerge@3.2.1': {} + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.1.0': + dependencies: + fast-json-stringify: 7.0.1 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/helmet@13.1.0': + dependencies: + fastify-plugin: 6.0.0 + helmet: 8.3.0 + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/multipart@10.1.0': + dependencies: + '@fastify/busboy': 3.2.0 + '@fastify/deepmerge': 3.2.1 + '@fastify/error': 4.2.0 + fastify-plugin: 6.0.0 + secure-json-parse: 4.1.0 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.4.0 + + '@fastify/rate-limit@11.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 6.0.0 + toad-cache: 3.7.4 + + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@10.1.0': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/error': 4.2.0 + '@fastify/send': 4.1.0 + content-disposition: 2.0.1 + fastify-plugin: 6.0.0 + fastq: 1.20.1 + glob: 13.0.6 + + '@fastify/websocket@11.3.0': + dependencies: + duplexify: 4.1.3 + fastify-plugin: 6.0.0 + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/utils@0.2.12': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@intlify/core-base@11.4.6': + dependencies: + '@intlify/devtools-types': 11.4.6 + '@intlify/message-compiler': 11.4.6 + '@intlify/shared': 11.4.6 + + '@intlify/devtools-types@11.4.6': + dependencies: + '@intlify/core-base': 11.4.6 + '@intlify/shared': 11.4.6 + + '@intlify/message-compiler@11.4.6': + dependencies: + '@intlify/shared': 11.4.6 + source-map-js: 1.2.1 + + '@intlify/shared@11.4.6': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.0 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.12': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/gen-mapping@0.3.5': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.4.10 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.4.10': {} + + '@jridgewell/sourcemap-codec@1.4.14': {} + + '@jridgewell/sourcemap-codec@1.4.15': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.4.14 + + '@lukeed/ms@2.0.2': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nuxt/kit@3.21.8': + dependencies: + c12: 3.3.4 + consola: 3.4.2 + defu: 6.1.7 + destr: 2.0.5 + errx: 0.1.0 + exsolve: 1.1.0 + ignore: 7.0.6 + jiti: 2.7.0 + klona: 2.0.6 + knitwork: 1.3.0 + mlly: 1.8.2 + ohash: 2.0.11 + pathe: 2.0.3 + pkg-types: 2.3.1 + rc9: 3.0.1 + scule: 1.3.0 + semver: 7.8.5 + tinyglobby: 0.2.17 + ufo: 1.6.4 + unctx: 2.5.0 + untyped: 2.0.0 + transitivePeerDependencies: + - magicast + + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-x64@2.5.6': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.6': + optional: true + + '@parcel/watcher-win32-arm64@2.5.6': + optional: true + + '@parcel/watcher-win32-ia32@2.5.6': + optional: true + + '@parcel/watcher-win32-x64@2.5.6': + optional: true + + '@parcel/watcher@2.5.6': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.0.0 + picomatch: 4.0.5 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 + optional: true + + '@pinojs/redact@0.4.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@pkgr/core@0.3.6': {} + + '@polka/url@1.0.0-next.29': {} + + '@rollup/pluginutils@5.4.0(rollup@4.62.2)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.5 + optionalDependencies: + rollup: 4.62.2 + + '@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 + + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@sxzz/popperjs-es@2.11.8': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.24 + + '@types/lodash@4.17.24': {} + + '@types/web-bluetooth@0.0.21': {} + + '@vitejs/plugin-vue@5.2.1(vite@6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.40)': + dependencies: + vite: 6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0) + vue: 3.5.40 + + '@vue/babel-helper-vue-transform-on@1.1.5': {} + + '@vue/babel-plugin-jsx@1.1.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@vue/babel-helper-vue-transform-on': 1.1.5 + camelcase: 6.3.0 + html-tags: 3.3.1 + svg-tags: 1.0.0 + transitivePeerDependencies: + - supports-color + + '@vue/compiler-core@3.5.40': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.40 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.40': + dependencies: + '@vue/compiler-core': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/compiler-sfc@3.5.40': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.40 + '@vue/compiler-dom': 3.5.40 + '@vue/compiler-ssr': 3.5.40 + '@vue/shared': 3.5.40 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.19 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.40': + dependencies: + '@vue/compiler-dom': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/devtools-api@6.5.0': {} + + '@vue/devtools-api@6.6.3': {} + + '@vue/devtools-api@6.6.4': {} + + '@vue/devtools-core@7.7.10(vite@6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.40)': + dependencies: + '@vue/devtools-kit': 7.7.10 + '@vue/devtools-shared': 7.7.10 + mitt: 3.0.1 + nanoid: 5.1.16 + pathe: 2.0.3 + vite-hot-client: 2.2.0(vite@6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0)) + vue: 3.5.40 + transitivePeerDependencies: + - vite + + '@vue/devtools-kit@7.7.10': + dependencies: + '@vue/devtools-shared': 7.7.10 + birpc: 2.3.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.10': + dependencies: + rfdc: 1.4.1 + + '@vue/eslint-config-prettier@10.2.0(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.5)': + dependencies: + eslint: 9.39.5(jiti@2.7.0) + eslint-config-prettier: 10.1.8(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-prettier: 5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.5) + prettier: 3.9.5 + transitivePeerDependencies: + - '@types/eslint' + + '@vue/reactivity@3.5.40': + dependencies: + '@vue/shared': 3.5.40 + + '@vue/runtime-core@3.5.40': + dependencies: + '@vue/reactivity': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/runtime-dom@3.5.40': + dependencies: + '@vue/reactivity': 3.5.40 + '@vue/runtime-core': 3.5.40 + '@vue/shared': 3.5.40 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.40': + dependencies: + '@vue/compiler-ssr': 3.5.40 + '@vue/runtime-dom': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/shared@3.5.40': {} + + '@vueuse/core@14.3.0(vue@3.5.40)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.3.0 + '@vueuse/shared': 14.3.0(vue@3.5.40) + vue: 3.5.40 + + '@vueuse/metadata@14.3.0': {} + + '@vueuse/shared@14.3.0(vue@3.5.40)': + dependencies: + vue: 3.5.40 + + abstract-logging@2.0.1: {} + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn-node@1.8.2: + dependencies: + acorn: 7.0.0 + acorn-walk: 7.0.0 + xtend: 4.0.2 + + acorn-walk@7.0.0: {} + + acorn@7.0.0: {} + + acorn@8.17.0: {} + + agent-base@6.0.0: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + async-validator@4.2.5: {} + + asynckit@0.4.0: {} + + atomic-sleep@1.0.0: {} + + autoprefixer@10.5.4(postcss@8.5.19): + dependencies: + browserslist: 4.28.6 + caniuse-lite: 1.0.30001806 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.19 + postcss-value-parser: 4.2.0 + + avvio@9.3.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + axios@1.18.1: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + balanced-match@1.0.0: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.43: {} + + better-sqlite3@12.11.1: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + binary-extensions@2.3.0: {} + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + birpc@2.3.0: {} + + bl@4.0.3: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + boolbase@1.0.0: {} + + brace-expansion@1.1.16: + dependencies: + balanced-match: 1.0.0 + concat-map: 0.0.1 + + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.0 + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.6: + dependencies: + baseline-browser-mapping: 2.10.43 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.393 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.6) + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.1.0 + giget: 3.3.0 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 3.0.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + camelcase-css@2.0.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001806: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + chownr@1.1.4: {} + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concat-map@0.0.1: {} + + concurrently@10.0.3: + dependencies: + chalk: 5.6.2 + rxjs: 7.8.2 + shell-quote: 1.8.4 + supports-color: 10.2.2 + tree-kill: 1.2.2 + yargs: 18.0.0 + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + consola@3.4.2: {} + + content-disposition@2.0.1: {} + + convert-source-map@2.0.0: {} + + cookie@1.0.1: {} + + cookie@2.0.1: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.1 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + dayjs@1.11.21: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + deep-is@0.1.4: {} + + deep-pick-omit@1.2.1: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + defined@1.0.1: {} + + defu@6.1.7: {} + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + detective@5.2.1: + dependencies: + acorn-node: 1.8.2 + defined: 1.0.1 + minimist: 1.2.8 + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + dotenv@17.4.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + + eastasianwidth@0.2.0: {} + + electron-to-chromium@1.5.393: {} + + element-plus@2.14.3(vue@3.5.40): + dependencies: + '@ctrl/tinycolor': 4.2.0 + '@element-plus/icons-vue': 2.3.2(vue@3.5.40) + '@floating-ui/dom': 1.8.0 + '@popperjs/core': '@sxzz/popperjs-es@2.11.8' + '@types/lodash': 4.17.24 + '@types/lodash-es': 4.17.12 + '@vueuse/core': 14.3.0(vue@3.5.40) + async-validator: 4.2.5 + dayjs: 1.11.21 + lodash: 4.18.1 + lodash-es: 4.18.1 + lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1) + memoize-one: 6.0.0 + normalize-wheel-es: 1.2.0 + vue: 3.5.40 + vue-component-type-helpers: 3.3.7 + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + entities@7.0.1: {} + + error-stack-parser-es@0.1.5: {} + + errx@0.1.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.25.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.0 + '@esbuild/android-arm': 0.25.0 + '@esbuild/android-arm64': 0.25.0 + '@esbuild/android-x64': 0.25.0 + '@esbuild/darwin-arm64': 0.25.0 + '@esbuild/darwin-x64': 0.25.0 + '@esbuild/freebsd-arm64': 0.25.0 + '@esbuild/freebsd-x64': 0.25.0 + '@esbuild/linux-arm': 0.25.0 + '@esbuild/linux-arm64': 0.25.0 + '@esbuild/linux-ia32': 0.25.0 + '@esbuild/linux-loong64': 0.25.0 + '@esbuild/linux-mips64el': 0.25.0 + '@esbuild/linux-ppc64': 0.25.0 + '@esbuild/linux-riscv64': 0.25.0 + '@esbuild/linux-s390x': 0.25.0 + '@esbuild/linux-x64': 0.25.0 + '@esbuild/netbsd-arm64': 0.25.0 + '@esbuild/netbsd-x64': 0.25.0 + '@esbuild/openbsd-arm64': 0.25.0 + '@esbuild/openbsd-x64': 0.25.0 + '@esbuild/sunos-x64': 0.25.0 + '@esbuild/win32-arm64': 0.25.0 + '@esbuild/win32-ia32': 0.25.0 + '@esbuild/win32-x64': 0.25.0 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)): + dependencies: + eslint: 9.39.5(jiti@2.7.0) + + eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.5): + dependencies: + eslint: 9.39.5(jiti@2.7.0) + prettier: 3.9.5 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.13 + optionalDependencies: + eslint-config-prettier: 10.1.8(eslint@9.39.5(jiti@2.7.0)) + + eslint-plugin-vue@9.32.0(eslint@9.39.5(jiti@2.7.0)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) + eslint: 9.39.5(jiti@2.7.0) + globals: 13.24.0 + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 6.1.4 + semver: 7.8.5 + vue-eslint-parser: 9.4.3(eslint@9.39.5(jiti@2.7.0)) + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - supports-color + + eslint-scope@7.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.3.0: {} + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.39.5(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.2.0 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + espree@9.3.1: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 3.3.0 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + execa@9.5.2: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + + expand-template@2.0.3: {} + + exsolve@1.1.0: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-json-stringify@7.0.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.0 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-levenshtein@2.0.6: {} + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.3: {} + + fast-uri@4.1.0: {} + + fastify-plugin@6.0.0: {} + + fastify@5.10.0: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.1.0 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.3.0 + fast-json-stringify: 7.0.1 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.5 + toad-cache: 3.7.4 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.0 + + file-uri-to-path@1.0.0: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.0: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + rimraf: 5.0.5 + + flatted@3.4.2: {} + + follow-redirects@1.16.0: {} + + foreground-child@3.1.0: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fraction.js@5.3.4: {} + + fs-constants@1.0.0: {} + + fs-extra@11.3.6: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + 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 + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + giget@3.3.0: {} + + github-from-package@0.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.1.0 + jackspeak: 3.1.2 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globals@14.0.0: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + helmet@8.3.0: {} + + hookable@5.5.3: {} + + html-tags@3.3.1: {} + + 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 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@8.0.1: {} + + ieee754@1.2.1: {} + + ignore@5.2.0: {} + + ignore@7.0.6: {} + + immutable@5.1.9: {} + + import-fresh@3.2.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ipaddr.js@2.4.0: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-number@7.0.0: {} + + is-plain-obj@4.1.0: {} + + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + + is-what@5.5.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + jackspeak@3.1.2: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + klona@2.0.6: {} + + knitwork@1.3.0: {} + + kolorist@1.8.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + light-my-request@6.6.0: + dependencies: + cookie: 1.0.1 + process-warning: 4.0.1 + set-cookie-parser: 2.6.0 + + lilconfig@2.1.0: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash-es@4.18.1: {} + + lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1): + dependencies: + '@types/lodash-es': 4.17.12 + lodash: 4.18.1 + lodash-es: 4.18.1 + + lodash.merge@4.6.2: {} + + lodash@4.18.1: {} + + lru-cache@10.2.0: {} + + lru-cache@11.5.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magic-string@0.30.4: + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + + math-intrinsics@1.1.0: {} + + memoize-one@6.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@3.0.0: {} + + mimic-response@3.1.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.16 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.2 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + mitt@3.0.1: {} + + mkdirp-classic@0.5.3: {} + + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + nanoid@5.1.16: {} + + napi-build-utils@2.0.0: {} + + natural-compare@1.4.0: {} + + node-abi@3.94.0: + dependencies: + semver: 7.8.5 + + node-addon-api@7.0.0: + optional: true + + node-releases@2.0.51: {} + + nodemailer@9.0.3: {} + + normalize-path@3.0.0: {} + + normalize-wheel-es@1.2.0: {} + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-hash@3.0.0: {} + + ohash@2.0.11: {} + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.0.2: + dependencies: + p-try: 2.2.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.0.2 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-ms@4.0.0: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.2.0 + minipass: 7.1.3 + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + pinia-plugin-persistedstate@4.2.0(pinia@2.3.1(vue@3.5.40)): + dependencies: + '@nuxt/kit': 3.21.8 + deep-pick-omit: 1.2.1 + defu: 6.1.7 + destr: 2.0.5 + optionalDependencies: + pinia: 2.3.1(vue@3.5.40) + transitivePeerDependencies: + - magicast + + pinia@2.3.1(vue@3.5.40): + dependencies: + '@vue/devtools-api': 6.6.3 + vue: 3.5.40 + vue-demi: 0.14.10(vue@3.5.40) + transitivePeerDependencies: + - '@vue/composition-api' + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.0 + pathe: 2.0.3 + + postcss-js@4.0.0(postcss@8.5.19): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.19 + + postcss-load-config@3.1.4(postcss@8.5.19): + dependencies: + lilconfig: 2.1.0 + yaml: 1.10.3 + optionalDependencies: + postcss: 8.5.19 + + postcss-nested@5.0.6(postcss@8.5.19): + dependencies: + postcss: 8.5.19 + postcss-selector-parser: 6.1.4 + + postcss-selector-parser@6.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.19: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.94.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.1: + dependencies: + fast-diff: 1.3.0 + + prettier@3.9.5: {} + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + proxy-from-env@2.1.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + quick-format-unescaped@4.0.4: {} + + quick-lru@5.1.1: {} + + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + readdirp@5.0.0: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rimraf@5.0.5: + dependencies: + glob: 10.5.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 + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + sass@1.101.0: + dependencies: + chokidar: 5.0.0 + immutable: 5.1.9 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.6 + + scule@1.3.0: {} + + secure-json-parse@4.1.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + set-cookie-parser@2.6.0: {} + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.4: {} + + signal-exit@4.1.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + speakingurl@14.0.1: {} + + split2@4.2.0: {} + + statuses@2.0.2: {} + + stream-shift@1.0.3: {} + + string-width@4.2.0: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-final-newline@4.0.0: {} + + strip-json-comments@2.0.1: {} + + strip-json-comments@3.1.1: {} + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + supports-color@10.2.2: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svg-tags@1.0.0: {} + + synckit@0.11.13: + dependencies: + '@pkgr/core': 0.3.6 + + tailwindcss@3.0.24(postcss@8.5.19): + dependencies: + arg: 5.0.2 + chokidar: 3.6.0 + color-name: 1.1.4 + detective: 5.2.1 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + lilconfig: 2.1.0 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.19 + postcss-js: 4.0.0(postcss@8.5.19) + postcss-load-config: 3.1.4(postcss@8.5.19) + postcss-nested: 5.0.6(postcss@8.5.19) + postcss-selector-parser: 6.1.4 + postcss-value-parser: 4.2.0 + quick-lru: 5.1.1 + resolve: 1.22.12 + transitivePeerDependencies: + - ts-node + + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.1.4 + + tar-stream@2.1.4: + dependencies: + bl: 4.0.3 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toad-cache@3.7.4: {} + + toidentifier@1.0.1: {} + + totalist@3.0.1: {} + + tree-kill@1.2.2: {} + + tslib@2.8.1: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + ufo@1.6.4: {} + + unctx@2.5.0: + dependencies: + acorn: 8.17.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + unplugin: 2.3.11 + + unicorn-magic@0.3.0: {} + + universalify@2.0.1: {} + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.17.0 + picomatch: 4.0.5 + webpack-virtual-modules: 0.6.2 + + untyped@2.0.0: + dependencies: + citty: 0.1.6 + defu: 6.1.7 + jiti: 2.7.0 + knitwork: 1.3.0 + scule: 1.3.0 + + update-browserslist-db@1.2.3(browserslist@4.28.6): + dependencies: + browserslist: 4.28.6 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vite-hot-client@2.2.0(vite@6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0)): + dependencies: + vite: 6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0) + + vite-plugin-inspect@0.8.9(rollup@4.62.2)(vite@6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0)): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.4.0(rollup@4.62.2) + debug: 4.4.3 + error-stack-parser-es: 0.1.5 + fs-extra: 11.3.6 + open: 10.2.0 + perfect-debounce: 1.0.0 + picocolors: 1.1.1 + sirv: 3.0.2 + vite: 6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0) + transitivePeerDependencies: + - rollup + - supports-color + + vite-plugin-vue-devtools@7.7.10(rollup@4.62.2)(vite@6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.40): + dependencies: + '@vue/devtools-core': 7.7.10(vite@6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.40) + '@vue/devtools-kit': 7.7.10 + '@vue/devtools-shared': 7.7.10 + execa: 9.5.2 + sirv: 3.0.2 + vite: 6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0) + vite-plugin-inspect: 0.8.9(rollup@4.62.2)(vite@6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0)) + vite-plugin-vue-inspector: 5.4.0(vite@6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0)) + transitivePeerDependencies: + - '@nuxt/kit' + - rollup + - supports-color + - vue + + vite-plugin-vue-inspector@5.4.0(vite@6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0)): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + '@vue/babel-plugin-jsx': 1.1.5(@babel/core@7.29.7) + '@vue/compiler-dom': 3.5.40 + kolorist: 1.8.0 + magic-string: 0.30.4 + vite: 6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + + vite@6.4.3(jiti@2.7.0)(sass@1.101.0)(yaml@2.9.0): + dependencies: + esbuild: 0.25.0 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.19 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + jiti: 2.7.0 + sass: 1.101.0 + yaml: 2.9.0 + + vue-component-type-helpers@3.3.7: {} + + vue-demi@0.14.10(vue@3.5.40): + dependencies: + vue: 3.5.40 + + vue-eslint-parser@9.4.3(eslint@9.39.5(jiti@2.7.0)): + dependencies: + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + eslint-scope: 7.1.1 + eslint-visitor-keys: 3.3.0 + espree: 9.3.1 + esquery: 1.7.0 + lodash: 4.18.1 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + vue-i18n@11.4.6(vue@3.5.40): + dependencies: + '@intlify/core-base': 11.4.6 + '@intlify/devtools-types': 11.4.6 + '@intlify/shared': 11.4.6 + '@vue/devtools-api': 6.5.0 + vue: 3.5.40 + + vue-router@4.6.4(vue@3.5.40): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.40 + + vue@3.5.40: + dependencies: + '@vue/compiler-dom': 3.5.40 + '@vue/compiler-sfc': 3.5.40 + '@vue/runtime-dom': 3.5.40 + '@vue/server-renderer': 3.5.40 + '@vue/shared': 3.5.40 + + webpack-virtual-modules@0.6.2: {} + + which@2.0.1: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.0 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrap-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + ws@8.21.1: {} + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + + xml-name-validator@4.0.0: {} + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml@1.10.3: {} + + yaml@2.9.0: {} + + yargs-parser@22.0.0: {} + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yoctocolors@2.1.2: {} - /zod@3.24.4: - resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==, tarball: https://registry.npmmirror.com/zod/-/zod-3.24.4.tgz} - dev: true + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..22054da --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +allowBuilds: + '@parcel/watcher': true + better-sqlite3: true + esbuild: true + vue-demi: true + +overrides: + 'ini@<1.3.6': 1.3.8 diff --git a/server/app.js b/server/app.js new file mode 100644 index 0000000..7992ce9 --- /dev/null +++ b/server/app.js @@ -0,0 +1,218 @@ +/* global process */ + +import { existsSync } from 'node:fs' + +import cookie from '@fastify/cookie' +import helmet from '@fastify/helmet' +import multipart from '@fastify/multipart' +import rateLimit from '@fastify/rate-limit' +import fastifyStatic from '@fastify/static' +import websocket from '@fastify/websocket' +import Fastify from 'fastify' + +import { defaultConfig, loadConfig } from './config.js' +import { errorDescriptor, sendFailure, sendSuccess } from './lib/http.js' +import authPlugin from './plugins/auth.js' +import adminRoutes from './routes/admin.js' +import assignmentRoutes from './routes/assignments.js' +import authRoutes from './routes/auth.js' +import appointmentRoutes from './routes/appointments.js' +import courseRoutes from './routes/courses.js' +import dialogueRoutes from './routes/dialogues.js' +import fileRoutes from './routes/files.js' +import notificationRoutes from './routes/notifications.js' +import profileRoutes from './routes/profile.js' +import realtimeRoutes from './routes/realtime.js' +import teacherRoutes from './routes/teachers.js' + +function resolveConfig(overrides = {}) { + const nodeEnv = overrides.nodeEnv || defaultConfig.nodeEnv + + return Object.freeze({ + ...defaultConfig, + ...overrides, + nodeEnv, + isProduction: overrides.isProduction ?? nodeEnv === 'production' + }) +} + +function isSpaNavigation(request) { + const pathname = request.url.split('?', 1)[0] + const accept = request.headers.accept || '' + + return ( + request.method === 'GET' && + accept.includes('text/html') && + !pathname.startsWith('/api/') && + !pathname.startsWith('/uploads/') && + !pathname.startsWith('/ws/') + ) +} + +function isServiceRequest(request) { + const pathname = request.url.split('?', 1)[0] + + return ( + pathname === '/api' || + pathname.startsWith('/api/') || + pathname === '/ws' || + pathname.startsWith('/ws/') + ) +} + +function probeDatabase(database) { + if (!database) return 'not_configured' + const result = database.prepare('SELECT 1 AS ok').get() + return result?.ok === 1 ? 'up' : 'down' +} + +export async function buildApp({ + database = null, + config: configOverrides = {} +} = {}) { + const config = resolveConfig(configOverrides) + const app = Fastify({ + bodyLimit: config.bodyLimit, + logger: config.logger, + trustProxy: config.trustProxy + }) + + app.decorate('config', config) + app.decorate('db', database) + app.decorate('database', database) + + await app.register(cookie) + await app.register(helmet, { + crossOriginEmbedderPolicy: false, + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + connectSrc: ["'self'", 'ws:', 'wss:'], + imgSrc: ["'self'", 'data:', 'blob:', 'https:'], + mediaSrc: ["'self'", 'blob:'], + scriptSrc: config.isProduction + ? ["'self'"] + : ["'self'", "'unsafe-eval'"], + styleSrc: ["'self'", "'unsafe-inline'"] + } + } + }) + await app.register(rateLimit, { + global: true, + max: config.rateLimitMax, + timeWindow: config.rateLimitWindow, + allowList: (request) => !isServiceRequest(request) + }) + await app.register(websocket) + await app.register(multipart, { + limits: { files: 1, fileSize: 50 * 1024 * 1024, fields: 10 } + }) + + if (database) { + await authPlugin(app, { + db: database, + allowBearer: config.allowBearer, + appOrigin: config.appOrigin, + secureCookies: config.secureCookies, + sessionTtlSeconds: config.sessionTtlSeconds + }) + await app.register(authRoutes) + await app.register(profileRoutes) + await app.register(courseRoutes) + await app.register(assignmentRoutes) + await app.register(fileRoutes) + await app.register(dialogueRoutes) + await app.register(notificationRoutes) + await app.register(realtimeRoutes) + await app.register(adminRoutes) + await app.register(teacherRoutes) + await app.register(appointmentRoutes) + } + + app.get('/api/v1/health', async (_request, reply) => + sendSuccess( + reply, + { + status: 'ok', + service: 'international-chinese-platform-api', + uptimeSeconds: Math.round(process.uptime() * 1000) / 1000, + timestamp: new Date().toISOString() + }, + { msg: 'healthy' } + ) + ) + + app.get('/api/v1/ready', async (request, reply) => { + let databaseStatus + + try { + databaseStatus = probeDatabase(database) + } catch (error) { + databaseStatus = 'down' + request.log.warn({ err: error }, 'Database readiness probe failed') + } + + if (databaseStatus !== 'up') { + return sendFailure(reply, { + statusCode: 503, + code: 'SERVICE_UNAVAILABLE', + msg: 'Service is not ready', + data: { + status: 'not_ready', + checks: { database: databaseStatus } + } + }) + } + + return sendSuccess( + reply, + { + status: 'ready', + checks: { database: databaseStatus } + }, + { msg: 'ready' } + ) + }) + + if (config.isProduction && existsSync(config.distDir)) { + await app.register(fastifyStatic, { + root: config.distDir, + decorateReply: true, + wildcard: false + }) + } + + app.setNotFoundHandler(async (request, reply) => { + if ( + config.isProduction && + existsSync(config.distDir) && + isSpaNavigation(request) + ) { + return reply.sendFile('index.html') + } + + return sendFailure(reply, { + statusCode: 404, + code: 'NOT_FOUND', + msg: 'Route not found' + }) + }) + + app.setErrorHandler((error, request, reply) => { + const descriptor = errorDescriptor(error) + + if (descriptor.statusCode >= 500) { + request.log.error({ err: error }, 'Unhandled request error') + } else { + request.log.info({ err: error }, 'Request rejected') + } + + return sendFailure(reply, descriptor) + }) + + return app +} + +export { loadConfig } + +export default buildApp diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..b62fb35 --- /dev/null +++ b/server/config.js @@ -0,0 +1,182 @@ +/* global process */ + +import { isAbsolute, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const PROJECT_ROOT = fileURLToPath(new URL('..', import.meta.url)) + +function integer( + value, + fallback, + name, + { min = 1, max = Number.MAX_SAFE_INTEGER } = {} +) { + const normalized = String(value ?? '').trim() + const parsed = + value === undefined || value === '' || normalized === '' + ? fallback + : /^\d+$/.test(normalized) + ? Number(normalized) + : Number.NaN + + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new TypeError(`${name} must be an integer between ${min} and ${max}`) + } + + return parsed +} + +function boolean(value, fallback, name) { + if (value === undefined || value === '') return fallback + const normalized = String(value).toLowerCase() + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true + if (['0', 'false', 'no', 'off'].includes(normalized)) return false + throw new TypeError(`${name} must be a boolean value`) +} + +function localPath(value, fallback) { + const target = value || fallback + if (target === ':memory:') return target + return isAbsolute(target) ? target : resolve(PROJECT_ROOT, target) +} + +export function loadConfig(env = process.env) { + const nodeEnv = env.NODE_ENV || 'development' + if (!['development', 'test', 'production'].includes(nodeEnv)) { + throw new TypeError( + 'NODE_ENV must be one of development, test, or production' + ) + } + const isProduction = nodeEnv === 'production' + const appOrigin = env.APP_ORIGIN || 'http://localhost:5173' + let parsedOrigin + try { + parsedOrigin = new URL(appOrigin) + } catch { + throw new TypeError('APP_ORIGIN must be a valid HTTP(S) origin') + } + if ( + !['http:', 'https:'].includes(parsedOrigin.protocol) || + parsedOrigin.origin !== appOrigin.replace(/\/$/, '') + ) { + throw new TypeError('APP_ORIGIN must contain only an HTTP(S) origin') + } + const loopbackOrigin = ['localhost', '127.0.0.1', '::1'].includes( + parsedOrigin.hostname + ) + if (isProduction && parsedOrigin.protocol !== 'https:' && !loopbackOrigin) { + throw new TypeError('Production APP_ORIGIN must use HTTPS') + } + + const verificationCodeSecret = env.VERIFICATION_CODE_SECRET || '' + if (isProduction && verificationCodeSecret.length < 32) { + throw new TypeError( + 'VERIFICATION_CODE_SECRET must contain at least 32 characters in production' + ) + } + + const secureCookies = boolean( + env.SECURE_COOKIES, + isProduction, + 'SECURE_COOKIES' + ) + if (isProduction && !loopbackOrigin && !secureCookies) { + throw new TypeError('SECURE_COOKIES cannot be disabled for a public origin') + } + + const aiApiUrl = env.AI_API_URL || '' + if (isProduction && aiApiUrl) { + let parsedAiUrl + try { + parsedAiUrl = new URL(aiApiUrl) + } catch { + throw new TypeError('AI_API_URL must be a valid URL') + } + if (parsedAiUrl.protocol !== 'https:') { + throw new TypeError('Production AI_API_URL must use HTTPS') + } + } + + const smtpUrl = env.SMTP_URL || '' + if (smtpUrl) { + let parsedSmtpUrl + try { + parsedSmtpUrl = new URL(smtpUrl) + } catch { + throw new TypeError('SMTP_URL must be a valid SMTP URL') + } + if (!['smtp:', 'smtps:'].includes(parsedSmtpUrl.protocol)) { + throw new TypeError('SMTP_URL must use smtp:// or smtps://') + } + } + + const sessionTtlHours = integer( + env.SESSION_TTL_HOURS, + 12, + 'SESSION_TTL_HOURS', + { + max: 24 * 365 + } + ) + const uploadOwnerQuotaBytes = integer( + env.UPLOAD_OWNER_QUOTA_BYTES, + 250 * 1024 * 1024, + 'UPLOAD_OWNER_QUOTA_BYTES' + ) + const uploadTotalQuotaBytes = integer( + env.UPLOAD_TOTAL_QUOTA_BYTES, + 5 * 1024 * 1024 * 1024, + 'UPLOAD_TOTAL_QUOTA_BYTES' + ) + if (uploadTotalQuotaBytes < uploadOwnerQuotaBytes) { + throw new TypeError( + 'UPLOAD_TOTAL_QUOTA_BYTES must be greater than or equal to UPLOAD_OWNER_QUOTA_BYTES' + ) + } + + return Object.freeze({ + nodeEnv, + isProduction, + host: env.HOST || '127.0.0.1', + port: integer(env.PORT, 7777, 'PORT', { max: 65_535 }), + appOrigin: parsedOrigin.origin, + databasePath: localPath(env.DATABASE_PATH, '.data/platform.db'), + uploadDir: localPath(env.UPLOAD_DIR, '.data/uploads'), + uploadOwnerQuotaBytes, + uploadTotalQuotaBytes, + uploadMaxConcurrent: integer( + env.UPLOAD_MAX_CONCURRENT, + 4, + 'UPLOAD_MAX_CONCURRENT', + { max: 32 } + ), + distDir: localPath(env.DIST_DIR, 'dist'), + bodyLimit: integer(env.BODY_LIMIT_BYTES, 1_048_576, 'BODY_LIMIT_BYTES'), + rateLimitMax: integer(env.RATE_LIMIT_MAX, 300, 'RATE_LIMIT_MAX'), + rateLimitWindow: env.RATE_LIMIT_WINDOW || '1 minute', + sessionTtlHours, + sessionTtlSeconds: sessionTtlHours * 60 * 60, + verificationCodeSecret, + secureCookies, + allowBearer: boolean(env.ALLOW_BEARER_AUTH, true, 'ALLOW_BEARER_AUTH'), + trustProxy: boolean(env.TRUST_PROXY, false, 'TRUST_PROXY'), + logger: boolean(env.LOGGER, nodeEnv !== 'test', 'LOGGER'), + seedOnStart: boolean( + env.SEED_ON_START, + nodeEnv !== 'production', + 'SEED_ON_START' + ), + smtpUrl, + mailFrom: + env.MAIL_FROM || 'International Chinese Platform ', + aiApiUrl, + aiApiKey: env.AI_API_KEY || '', + turnUrl: env.TURN_URL || '', + turnUsername: env.TURN_USERNAME || '', + turnCredential: env.TURN_CREDENTIAL || '' + }) +} + +export const defaultConfig = loadConfig() + +export default defaultConfig diff --git a/server/db/bootstrap-admin.js b/server/db/bootstrap-admin.js new file mode 100644 index 0000000..eae941f --- /dev/null +++ b/server/db/bootstrap-admin.js @@ -0,0 +1,147 @@ +import { randomUUID } from 'node:crypto' +import { resolve } from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +import { z } from 'zod' + +import { createDatabase, DEFAULT_DATABASE_FILE } from './database.js' +import { hashPassword } from '../lib/password.js' + +const bootstrapSchema = z + .object({ + email: z + .string() + .trim() + .email() + .max(254) + .transform((value) => value.toLowerCase()), + password: z + .string() + .min(12, '管理员密码至少需要 12 个字符') + .max(128) + .regex(/[a-z]/, '管理员密码必须包含小写字母') + .regex(/[A-Z]/, '管理员密码必须包含大写字母') + .regex(/\d/, '管理员密码必须包含数字') + .regex(/[^A-Za-z0-9]/, '管理员密码必须包含特殊字符'), + displayName: z.string().trim().min(1).max(100) + }) + .strict() + +function bootstrapError(code, message) { + const error = new Error(message) + error.code = code + return error +} + +export async function bootstrapAdministrator(database, input) { + if (!database?.open) { + throw new TypeError('bootstrapAdministrator requires an open database') + } + + const result = bootstrapSchema.safeParse(input) + if (!result.success) { + const message = result.error.issues.map((issue) => issue.message).join(';') + throw bootstrapError('INVALID_ADMIN_INPUT', message) + } + + const existingAdministrator = database + .prepare("SELECT id FROM users WHERE role = 'administrator' LIMIT 1") + .get() + if (existingAdministrator) { + throw bootstrapError( + 'ADMIN_ALREADY_EXISTS', + '管理员已存在;引导命令不会覆盖现有账号' + ) + } + + const existingEmail = database + .prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1') + .get(result.data.email) + if (existingEmail) { + throw bootstrapError('EMAIL_IN_USE', '该邮箱已被其他账号使用') + } + + const id = randomUUID() + const passwordHash = await hashPassword(result.data.password) + const timestamp = new Date().toISOString() + + const create = database.transaction(() => { + if ( + database + .prepare("SELECT id FROM users WHERE role = 'administrator' LIMIT 1") + .get() + ) { + throw bootstrapError( + 'ADMIN_ALREADY_EXISTS', + '管理员已存在;引导命令不会覆盖现有账号' + ) + } + + database + .prepare( + `INSERT INTO users ( + id, email, password_hash, role, display_name, status, created_at, updated_at + ) VALUES (?, ?, ?, 'administrator', ?, 'active', ?, ?)` + ) + .run( + id, + result.data.email, + passwordHash, + result.data.displayName, + timestamp, + timestamp + ) + database + .prepare( + `INSERT INTO audit_logs ( + id, actor_id, action, entity_type, entity_id, details_json, created_at + ) VALUES (?, ?, 'administrator.bootstrapped', 'user', ?, ?, ?)` + ) + .run( + randomUUID(), + id, + id, + JSON.stringify({ source: 'admin:bootstrap' }), + timestamp + ) + }) + create() + + return { + id, + email: result.data.email, + displayName: result.data.displayName, + role: 'administrator' + } +} + +function isMainModule() { + return ( + Boolean(process.argv[1]) && + pathToFileURL(resolve(process.argv[1])).href === import.meta.url + ) +} + +if (isMainModule()) { + const database = createDatabase({ + filename: process.env.DATABASE_PATH || DEFAULT_DATABASE_FILE + }) + try { + const administrator = await bootstrapAdministrator(database, { + email: process.env.ADMIN_EMAIL, + password: process.env.ADMIN_PASSWORD, + displayName: process.env.ADMIN_DISPLAY_NAME || '平台管理员' + }) + process.stdout.write( + `Administrator created: ${administrator.email} (${administrator.id})\n` + ) + } catch (error) { + process.stderr.write(`Administrator bootstrap failed: ${error.message}\n`) + process.exitCode = 1 + } finally { + database.close() + } +} + +export default bootstrapAdministrator diff --git a/server/db/database.js b/server/db/database.js new file mode 100644 index 0000000..3fbe914 --- /dev/null +++ b/server/db/database.js @@ -0,0 +1,141 @@ +import Database from 'better-sqlite3' +import { mkdirSync, readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { seedDatabase } from './seed-data.js' + +const projectRoot = fileURLToPath(new URL('../..', import.meta.url)) +const schemaSql = readFileSync(new URL('./schema.sql', import.meta.url), 'utf8') + +function tableExists(database, tableName) { + return Boolean( + database + .prepare( + `SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = ? LIMIT 1` + ) + .get(tableName) + ) +} + +function columnExists(database, tableName, columnName) { + if (!tableExists(database, tableName)) return false + return database + .prepare(`PRAGMA table_info(${tableName})`) + .all() + .some((column) => column.name === columnName) +} + +const migrations = Object.freeze([ + { + id: '001_initial_schema', + up(database) { + // A new installation gets the current canonical schema in one pass. + // Existing pre-ledger databases are adopted and upgraded below. + if (!tableExists(database, 'users')) database.exec(schemaSql) + } + }, + { + id: '002_realtime_message_dedupe', + up(database) { + if ( + tableExists(database, 'chat_messages') && + !columnExists(database, 'chat_messages', 'client_message_id') + ) { + database.exec( + 'ALTER TABLE chat_messages ADD COLUMN client_message_id TEXT' + ) + } + } + }, + { + id: '003_platform_extensions', + up(database) { + // Creates domain tables introduced after the baseline and reconciles all + // indexes only after their required columns have been added. + database.exec(schemaSql) + } + } +]) + +export const MIGRATION_IDS = Object.freeze( + migrations.map((migration) => migration.id) +) + +export const DEFAULT_DATABASE_FILE = process.env.DATABASE_PATH + ? resolve(process.env.DATABASE_PATH) + : resolve(projectRoot, '.data', 'platform.db') + +function configureDatabase(database) { + database.pragma('foreign_keys = ON') + database.pragma('busy_timeout = 5000') + database.pragma('journal_mode = WAL') + database.pragma('synchronous = NORMAL') +} + +export function migrateDatabase(database) { + if (!database || typeof database.exec !== 'function') { + throw new TypeError( + 'migrateDatabase requires an open better-sqlite3 database' + ) + } + + database.exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ) + `) + + const applied = new Set( + database + .prepare('SELECT id FROM schema_migrations ORDER BY applied_at, id') + .all() + .map((row) => row.id) + ) + const recordMigration = database.prepare( + 'INSERT INTO schema_migrations (id) VALUES (?)' + ) + + for (const migration of migrations) { + if (applied.has(migration.id)) continue + + database.transaction(() => { + migration.up(database) + recordMigration.run(migration.id) + })() + } + + return database +} + +export function createDatabase({ + filename = DEFAULT_DATABASE_FILE, + seed = false +} = {}) { + if (typeof filename !== 'string' || filename.length === 0) { + throw new TypeError('Database filename must be a non-empty string') + } + + if (filename !== ':memory:') { + mkdirSync(dirname(resolve(filename)), { recursive: true }) + } + + const database = new Database(filename) + + try { + configureDatabase(database) + migrateDatabase(database) + + if (seed) { + seedDatabase(database) + } + + return database + } catch (error) { + database.close() + throw error + } +} diff --git a/server/db/migrate.js b/server/db/migrate.js new file mode 100644 index 0000000..578329e --- /dev/null +++ b/server/db/migrate.js @@ -0,0 +1,25 @@ +import { pathToFileURL } from 'node:url' +import { resolve } from 'node:path' +import process from 'node:process' + +import { + createDatabase, + DEFAULT_DATABASE_FILE, + migrateDatabase +} from './database.js' + +export { migrateDatabase } + +function isMainModule() { + return ( + Boolean(process.argv[1]) && + pathToFileURL(resolve(process.argv[1])).href === import.meta.url + ) +} + +if (isMainModule()) { + const filename = process.argv[2] || DEFAULT_DATABASE_FILE + const database = createDatabase({ filename }) + database.close() + process.stdout.write(`Database migration complete: ${filename}\n`) +} diff --git a/server/db/reset.js b/server/db/reset.js new file mode 100644 index 0000000..0f3f1e8 --- /dev/null +++ b/server/db/reset.js @@ -0,0 +1,48 @@ +import { existsSync, lstatSync, unlinkSync } from 'node:fs' +import { resolve } from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +import { createDatabase, DEFAULT_DATABASE_FILE } from './database.js' + +function removeDatabaseFile(filename) { + for (const candidate of [filename, `${filename}-shm`, `${filename}-wal`]) { + if (!existsSync(candidate)) { + continue + } + + if (!lstatSync(candidate).isFile()) { + throw new Error(`Refusing to remove non-file database path: ${candidate}`) + } + + unlinkSync(candidate) + } +} + +export function resetDatabase({ filename = DEFAULT_DATABASE_FILE } = {}) { + if (typeof filename !== 'string' || filename.length === 0) { + throw new TypeError('Database filename must be a non-empty string') + } + + if (filename === ':memory:') { + return createDatabase({ filename, seed: true }) + } + + const resolvedFilename = resolve(filename) + removeDatabaseFile(resolvedFilename) + return createDatabase({ filename: resolvedFilename, seed: true }) +} + +function isMainModule() { + return ( + Boolean(process.argv[1]) && + pathToFileURL(resolve(process.argv[1])).href === import.meta.url + ) +} + +if (isMainModule()) { + const filename = process.argv[2] || DEFAULT_DATABASE_FILE + const database = resetDatabase({ filename }) + database.close() + process.stdout.write(`Database reset complete: ${filename}\n`) +} diff --git a/server/db/schema.sql b/server/db/schema.sql new file mode 100644 index 0000000..e4c6937 --- /dev/null +++ b/server/db/schema.sql @@ -0,0 +1,331 @@ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL COLLATE NOCASE UNIQUE + CHECK (length(email) BETWEEN 3 AND 254 AND instr(email, '@') > 1), + password_hash TEXT NOT NULL CHECK (length(password_hash) >= 32), + role TEXT NOT NULL CHECK (role IN ('student', 'teacher', 'administrator')), + display_name TEXT NOT NULL CHECK (length(trim(display_name)) BETWEEN 1 AND 100), + avatar_url TEXT, + country TEXT, + region TEXT, + age INTEGER CHECK (age IS NULL OR age BETWEEN 6 AND 120), + chinese_level TEXT, + bio TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE IF NOT EXISTS teacher_profiles ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + school TEXT NOT NULL DEFAULT '' CHECK (length(school) <= 160), + title TEXT NOT NULL DEFAULT '' CHECK (length(title) <= 120), + experience_years INTEGER NOT NULL DEFAULT 0 CHECK (experience_years BETWEEN 0 AND 80), + rating REAL NOT NULL DEFAULT 5 CHECK (rating BETWEEN 0 AND 5), + hourly_rate_cents INTEGER NOT NULL DEFAULT 0 CHECK (hourly_rate_cents >= 0), + specialties_json TEXT NOT NULL DEFAULT '[]', + certificates_json TEXT NOT NULL DEFAULT '[]', + teaching_style_json TEXT NOT NULL DEFAULT '[]', + languages_json TEXT NOT NULL DEFAULT '[]', + verified_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE CHECK (length(token_hash) BETWEEN 43 AND 64), + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + revoked_at TEXT, + user_agent TEXT, + ip_address TEXT, + CHECK (expires_at > created_at), + CHECK (revoked_at IS NULL OR revoked_at >= created_at) +); + +CREATE TABLE IF NOT EXISTS verification_codes ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL COLLATE NOCASE + CHECK (length(email) BETWEEN 3 AND 254 AND instr(email, '@') > 1), + purpose TEXT NOT NULL CHECK (purpose IN ('register', 'reset_password', 'change_email')), + code_hash TEXT NOT NULL CHECK (length(code_hash) = 64), + expires_at TEXT NOT NULL, + consumed_at TEXT, + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts BETWEEN 0 AND 10), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (expires_at > created_at), + CHECK (consumed_at IS NULL OR consumed_at >= created_at) +); + +CREATE TABLE IF NOT EXISTS courses ( + id TEXT PRIMARY KEY, + teacher_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, + title TEXT NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 160), + summary TEXT NOT NULL DEFAULT '' CHECK (length(summary) <= 500), + description TEXT NOT NULL DEFAULT '', + level TEXT NOT NULL DEFAULT 'beginner' + CHECK (level IN ('beginner', 'elementary', 'intermediate', 'advanced', 'all')), + category TEXT NOT NULL DEFAULT 'general' CHECK (length(category) BETWEEN 1 AND 80), + cover_url TEXT, + duration_minutes INTEGER NOT NULL DEFAULT 60 CHECK (duration_minutes BETWEEN 1 AND 1440), + price_cents INTEGER NOT NULL DEFAULT 0 CHECK (price_cents >= 0), + capacity INTEGER NOT NULL DEFAULT 30 CHECK (capacity BETWEEN 1 AND 10000), + status TEXT NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'pending', 'published', 'rejected', 'archived')), + rejection_reason TEXT, + published_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (status = 'rejected' OR rejection_reason IS NULL), + CHECK (status = 'published' OR published_at IS NULL) +); + +CREATE TABLE IF NOT EXISTS course_reviews ( + id TEXT PRIMARY KEY, + course_id TEXT NOT NULL REFERENCES courses(id) ON UPDATE CASCADE ON DELETE CASCADE, + reviewer_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, + decision TEXT NOT NULL CHECK (decision IN ('approved', 'rejected')), + review_note TEXT NOT NULL DEFAULT '' CHECK (length(review_note) <= 2000), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE IF NOT EXISTS appointments ( + id TEXT PRIMARY KEY, + student_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, + teacher_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, + course_id TEXT REFERENCES courses(id) ON UPDATE CASCADE ON DELETE SET NULL, + scheduled_start TEXT NOT NULL, + scheduled_end TEXT NOT NULL, + topic TEXT NOT NULL CHECK (length(trim(topic)) BETWEEN 1 AND 160), + message TEXT NOT NULL DEFAULT '' CHECK (length(message) <= 2000), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'accepted', 'rejected', 'cancelled', 'completed')), + response_note TEXT NOT NULL DEFAULT '' CHECK (length(response_note) <= 2000), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (student_id <> teacher_id), + CHECK (scheduled_end > scheduled_start) +); + +CREATE TABLE IF NOT EXISTS classrooms ( + id TEXT PRIMARY KEY, + appointment_id TEXT NOT NULL UNIQUE + REFERENCES appointments(id) ON UPDATE CASCADE ON DELETE CASCADE, + room_code TEXT NOT NULL UNIQUE CHECK (length(room_code) BETWEEN 6 AND 64), + status TEXT NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'open', 'closed')), + opened_at TEXT, + closed_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (closed_at IS NULL OR opened_at IS NOT NULL), + CHECK (closed_at IS NULL OR closed_at >= opened_at) +); + +CREATE TABLE IF NOT EXISTS assignments ( + id TEXT PRIMARY KEY, + course_id TEXT NOT NULL REFERENCES courses(id) ON UPDATE CASCADE ON DELETE CASCADE, + teacher_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, + title TEXT NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 160), + instructions TEXT NOT NULL DEFAULT '', + due_at TEXT, + max_score REAL NOT NULL DEFAULT 100 CHECK (max_score > 0), + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'closed')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE (course_id, title) +); + +CREATE TABLE IF NOT EXISTS assignment_questions ( + id TEXT PRIMARY KEY, + assignment_id TEXT NOT NULL REFERENCES assignments(id) ON UPDATE CASCADE ON DELETE CASCADE, + position INTEGER NOT NULL CHECK (position > 0), + question_type TEXT NOT NULL CHECK (question_type IN ('single_choice', 'multiple_choice', 'text')), + prompt TEXT NOT NULL CHECK (length(trim(prompt)) > 0), + options_json TEXT, + correct_answer_json TEXT, + points REAL NOT NULL CHECK (points > 0), + explanation TEXT NOT NULL DEFAULT '', + UNIQUE (assignment_id, position), + CHECK (question_type = 'text' OR options_json IS NOT NULL) +); + +CREATE TABLE IF NOT EXISTS submissions ( + id TEXT PRIMARY KEY, + assignment_id TEXT NOT NULL REFERENCES assignments(id) ON UPDATE CASCADE ON DELETE CASCADE, + student_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, + answers_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'graded')), + submitted_at TEXT, + score REAL CHECK (score IS NULL OR score >= 0), + feedback TEXT NOT NULL DEFAULT '' CHECK (length(feedback) <= 5000), + graded_by TEXT REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, + graded_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE (assignment_id, student_id), + CHECK (status = 'draft' OR submitted_at IS NOT NULL), + CHECK ( + status <> 'graded' + OR (score IS NOT NULL AND graded_by IS NOT NULL AND graded_at IS NOT NULL) + ) +); + +CREATE TABLE IF NOT EXISTS chat_messages ( + id TEXT PRIMARY KEY, + classroom_id TEXT NOT NULL REFERENCES classrooms(id) ON UPDATE CASCADE ON DELETE CASCADE, + sender_id TEXT REFERENCES users(id) ON UPDATE CASCADE ON DELETE SET NULL, + message_type TEXT NOT NULL DEFAULT 'text' + CHECK (message_type IN ('text', 'system', 'hand_raise')), + content TEXT NOT NULL CHECK (length(content) BETWEEN 1 AND 4000), + metadata_json TEXT NOT NULL DEFAULT '{}', + client_message_id TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + edited_at TEXT, + deleted_at TEXT, + CHECK (edited_at IS NULL OR edited_at >= created_at), + CHECK (deleted_at IS NULL OR deleted_at >= created_at) +); + +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + type TEXT NOT NULL CHECK (length(type) BETWEEN 1 AND 80), + title TEXT NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 160), + body TEXT NOT NULL DEFAULT '' CHECK (length(body) <= 4000), + resource_type TEXT, + resource_id TEXT, + link TEXT, + dedupe_key TEXT, + read_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (read_at IS NULL OR read_at >= created_at) +); + +CREATE TABLE IF NOT EXISTS audit_logs ( + id TEXT PRIMARY KEY, + actor_id TEXT REFERENCES users(id) ON UPDATE CASCADE ON DELETE SET NULL, + action TEXT NOT NULL CHECK (length(action) BETWEEN 1 AND 120), + entity_type TEXT NOT NULL CHECK (length(entity_type) BETWEEN 1 AND 80), + entity_id TEXT, + details_json TEXT NOT NULL DEFAULT '{}', + request_id TEXT, + ip_address TEXT, + user_agent TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE IF NOT EXISTS files ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + storage_key TEXT NOT NULL UNIQUE CHECK (length(storage_key) BETWEEN 1 AND 255), + original_name TEXT NOT NULL CHECK (length(original_name) BETWEEN 1 AND 255), + mime_type TEXT NOT NULL CHECK (length(mime_type) BETWEEN 1 AND 120), + size_bytes INTEGER NOT NULL CHECK (size_bytes BETWEEN 1 AND 52428800), + sha256 TEXT NOT NULL CHECK (length(sha256) = 64), + category TEXT NOT NULL CHECK (category IN ('avatar', 'course_cover', 'course_video', 'course_material')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE IF NOT EXISTS dialogue_sessions ( + id TEXT PRIMARY KEY, + student_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + title TEXT NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 160), + keywords_json TEXT NOT NULL DEFAULT '[]', + provider TEXT NOT NULL DEFAULT 'local', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE IF NOT EXISTS dialogue_turns ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES dialogue_sessions(id) ON UPDATE CASCADE ON DELETE CASCADE, + position INTEGER NOT NULL CHECK (position > 0), + speaker TEXT NOT NULL CHECK (speaker IN ('student', 'tutor', 'system')), + content TEXT NOT NULL CHECK (length(trim(content)) BETWEEN 1 AND 4000), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE (session_id, position) +); + +CREATE INDEX IF NOT EXISTS users_role_status_idx + ON users (role, status); + +CREATE INDEX IF NOT EXISTS teacher_profiles_discovery_idx + ON teacher_profiles (rating DESC, experience_years DESC); + +CREATE INDEX IF NOT EXISTS sessions_user_active_idx + ON sessions (user_id, expires_at) + WHERE revoked_at IS NULL; + +CREATE INDEX IF NOT EXISTS sessions_expiry_idx + ON sessions (expires_at); + +CREATE INDEX IF NOT EXISTS verification_codes_lookup_idx + ON verification_codes (email, purpose, created_at DESC) + WHERE consumed_at IS NULL; + +CREATE INDEX IF NOT EXISTS courses_teacher_status_idx + ON courses (teacher_id, status, updated_at DESC); + +CREATE INDEX IF NOT EXISTS courses_catalog_idx + ON courses (status, level, category, published_at DESC); + +CREATE INDEX IF NOT EXISTS course_reviews_course_idx + ON course_reviews (course_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS appointments_student_idx + ON appointments (student_id, status, scheduled_start); + +CREATE INDEX IF NOT EXISTS appointments_teacher_idx + ON appointments (teacher_id, status, scheduled_start); + +CREATE UNIQUE INDEX IF NOT EXISTS appointments_active_request_uq + ON appointments (student_id, teacher_id, scheduled_start) + WHERE status IN ('pending', 'accepted'); + +CREATE INDEX IF NOT EXISTS classrooms_status_idx + ON classrooms (status, created_at DESC); + +CREATE INDEX IF NOT EXISTS assignments_course_status_idx + ON assignments (course_id, status, due_at); + +CREATE INDEX IF NOT EXISTS assignments_teacher_status_idx + ON assignments (teacher_id, status, updated_at DESC); + +CREATE INDEX IF NOT EXISTS submissions_assignment_status_idx + ON submissions (assignment_id, status, submitted_at DESC); + +CREATE INDEX IF NOT EXISTS submissions_student_status_idx + ON submissions (student_id, status, updated_at DESC); + +CREATE INDEX IF NOT EXISTS chat_messages_history_idx + ON chat_messages (classroom_id, created_at, id); + +CREATE UNIQUE INDEX IF NOT EXISTS chat_messages_client_dedupe_uq + ON chat_messages (sender_id, client_message_id) + WHERE sender_id IS NOT NULL AND client_message_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS notifications_user_created_idx + ON notifications (user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS notifications_user_unread_idx + ON notifications (user_id, created_at DESC) + WHERE read_at IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS notifications_dedupe_uq + ON notifications (user_id, dedupe_key) + WHERE dedupe_key IS NOT NULL; + +CREATE INDEX IF NOT EXISTS audit_logs_actor_created_idx + ON audit_logs (actor_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS audit_logs_entity_idx + ON audit_logs (entity_type, entity_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS files_owner_category_idx + ON files (owner_id, category, created_at DESC); + +CREATE INDEX IF NOT EXISTS dialogue_sessions_student_idx + ON dialogue_sessions (student_id, updated_at DESC); diff --git a/server/db/seed-data.js b/server/db/seed-data.js new file mode 100644 index 0000000..9ec269f --- /dev/null +++ b/server/db/seed-data.js @@ -0,0 +1,529 @@ +import { Buffer } from 'node:buffer' +import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto' + +const SCRYPT_N = 2 ** 14 +const SCRYPT_R = 8 +const SCRYPT_P = 1 +const SCRYPT_KEY_LENGTH = 64 +const SCRYPT_MAX_MEMORY = 64 * 1024 * 1024 + +export const DEMO_PASSWORD = 'Demo123!' + +export const DEMO_ACCOUNTS = Object.freeze([ + Object.freeze({ + id: '00000000-0000-4000-8000-000000000001', + email: 'student@example.com', + role: 'student', + displayName: '林晓雨' + }), + Object.freeze({ + id: '00000000-0000-4000-8000-000000000002', + email: 'teacher@example.com', + role: 'teacher', + displayName: '王老师' + }), + Object.freeze({ + id: '00000000-0000-4000-8000-000000000003', + email: 'admin@example.com', + role: 'administrator', + displayName: '平台管理员' + }) +]) + +export const DEMO_IDS = Object.freeze({ + publishedCourse: '10000000-0000-4000-8000-000000000001', + pendingCourse: '10000000-0000-4000-8000-000000000002', + review: '20000000-0000-4000-8000-000000000001', + acceptedAppointment: '30000000-0000-4000-8000-000000000001', + pendingAppointment: '30000000-0000-4000-8000-000000000002', + classroom: '40000000-0000-4000-8000-000000000001', + assignment: '50000000-0000-4000-8000-000000000001', + questionChoice: '60000000-0000-4000-8000-000000000001', + questionText: '60000000-0000-4000-8000-000000000002', + submission: '70000000-0000-4000-8000-000000000001', + message: '80000000-0000-4000-8000-000000000001', + studentNotification: '90000000-0000-4000-8000-000000000001', + teacherNotification: '90000000-0000-4000-8000-000000000002', + auditLog: 'a0000000-0000-4000-8000-000000000001' +}) + +/** + * Create a self-describing scrypt password hash. + * + * Format: scrypt$N$r$p$keyLength$base64url(salt)$base64url(digest) + */ +export function hashPassword(password, salt = randomBytes(16)) { + if ( + typeof password !== 'string' || + password.length < 8 || + password.length > 128 + ) { + throw new TypeError('Password must contain between 8 and 128 characters') + } + + const digest = scryptSync(password, salt, SCRYPT_KEY_LENGTH, { + N: SCRYPT_N, + r: SCRYPT_R, + p: SCRYPT_P, + maxmem: SCRYPT_MAX_MEMORY + }) + + return [ + 'scrypt', + SCRYPT_N, + SCRYPT_R, + SCRYPT_P, + SCRYPT_KEY_LENGTH, + salt.toString('base64url'), + digest.toString('base64url') + ].join('$') +} + +export function verifyPasswordHash(password, encodedHash) { + if (typeof password !== 'string' || typeof encodedHash !== 'string') { + return false + } + + const [algorithm, n, r, p, keyLength, saltValue, digestValue, extra] = + encodedHash.split('$') + const parameters = [n, r, p, keyLength].map(Number) + + if ( + algorithm !== 'scrypt' || + extra !== undefined || + parameters.some((value) => !Number.isSafeInteger(value) || value <= 0) || + parameters[0] > SCRYPT_N || + parameters[1] > SCRYPT_R || + parameters[2] > SCRYPT_P || + parameters[3] > SCRYPT_KEY_LENGTH + ) { + return false + } + + try { + const salt = Buffer.from(saltValue, 'base64url') + const expected = Buffer.from(digestValue, 'base64url') + + if (salt.length < 16 || expected.length !== parameters[3]) { + return false + } + + const actual = scryptSync(password, salt, parameters[3], { + N: parameters[0], + r: parameters[1], + p: parameters[2], + maxmem: SCRYPT_MAX_MEMORY + }) + + return timingSafeEqual(actual, expected) + } catch { + return false + } +} + +function insertUsers(database) { + const insert = database.prepare(` + INSERT OR IGNORE INTO users ( + id, email, password_hash, role, display_name, country, region, age, + chinese_level, bio, status, created_at, updated_at + ) VALUES ( + @id, @email, @passwordHash, @role, @displayName, @country, @region, @age, + @chineseLevel, @bio, 'active', @createdAt, @updatedAt + ) + `) + + const profiles = { + student: { + country: 'France', + region: 'Île-de-France', + age: 22, + chineseLevel: 'HSK 3', + bio: '热爱旅行和中国文化的中文学习者。' + }, + teacher: { + country: 'China', + region: 'Beijing', + age: 34, + chineseLevel: '母语', + bio: '专注国际中文教育与跨文化口语教学。' + }, + administrator: { + country: 'China', + region: 'Shanghai', + age: 30, + chineseLevel: '母语', + bio: '负责课程质量审核与平台运营。' + } + } + + for (const account of DEMO_ACCOUNTS) { + insert.run({ + ...account, + ...profiles[account.role], + passwordHash: hashPassword(DEMO_PASSWORD), + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-07-01T00:00:00.000Z' + }) + } + + return Object.fromEntries( + DEMO_ACCOUNTS.map((account) => { + const row = database + .prepare('SELECT id FROM users WHERE email = ?') + .get(account.email) + + if (!row) { + throw new Error(`Unable to resolve seeded account: ${account.email}`) + } + + return [account.role, row.id] + }) + ) +} + +function insertCourses(database, userIds) { + const insert = database.prepare(` + INSERT OR IGNORE INTO courses ( + id, teacher_id, title, summary, description, level, category, cover_url, + duration_minutes, price_cents, capacity, status, rejection_reason, + published_at, created_at, updated_at + ) VALUES ( + @id, @teacherId, @title, @summary, @description, @level, @category, NULL, + @durationMinutes, @priceCents, @capacity, @status, NULL, + @publishedAt, @createdAt, @updatedAt + ) + `) + + insert.run({ + id: DEMO_IDS.publishedCourse, + teacherId: userIds.teacher, + title: '生活汉语入门', + summary: '从真实生活场景开始,自信完成问候、点餐、购物与出行对话。', + description: + '面向初学者的结构化课程,包含情景会话、发音训练、文化提示与课后练习。', + level: 'beginner', + category: '综合汉语', + durationMinutes: 45, + priceCents: 9900, + capacity: 20, + status: 'published', + publishedAt: '2026-04-08T09:30:00.000Z', + createdAt: '2026-04-01T08:00:00.000Z', + updatedAt: '2026-04-08T09:30:00.000Z' + }) + + insert.run({ + id: DEMO_IDS.pendingCourse, + teacherId: userIds.teacher, + title: '中国文化与口语表达', + summary: '围绕节日、饮食与城市生活开展中级口语讨论。', + description: '通过主题材料和课堂任务训练观点表达、追问与跨文化沟通。', + level: 'intermediate', + category: '文化口语', + durationMinutes: 60, + priceCents: 12900, + capacity: 16, + status: 'pending', + publishedAt: null, + createdAt: '2026-07-10T03:00:00.000Z', + updatedAt: '2026-07-15T05:00:00.000Z' + }) + + database + .prepare( + ` + INSERT OR IGNORE INTO course_reviews ( + id, course_id, reviewer_id, decision, review_note, created_at + ) VALUES (?, ?, ?, 'approved', ?, ?) + ` + ) + .run( + DEMO_IDS.review, + DEMO_IDS.publishedCourse, + userIds.administrator, + '课程目标、内容结构和演示材料完整,同意发布。', + '2026-04-08T09:30:00.000Z' + ) +} + +function insertTeacherProfile(database, userIds) { + database + .prepare( + ` + INSERT OR IGNORE INTO teacher_profiles ( + user_id, school, title, experience_years, rating, hourly_rate_cents, + specialties_json, certificates_json, teaching_style_json, + languages_json, verified_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + ) + .run( + userIds.teacher, + '北京语言大学', + '国际中文教育讲师', + 8, + 4.9, + 18800, + JSON.stringify(['生活口语', 'HSK 备考', '中国文化']), + JSON.stringify(['国际中文教师证书', '普通话一级乙等']), + JSON.stringify(['耐心引导', '情景教学', '即时反馈']), + JSON.stringify(['中文', '英语']), + '2026-03-15T08:00:00.000Z', + '2026-03-15T08:00:00.000Z', + '2026-07-01T00:00:00.000Z' + ) +} + +function insertAppointments(database, userIds) { + const insert = database.prepare(` + INSERT OR IGNORE INTO appointments ( + id, student_id, teacher_id, course_id, scheduled_start, scheduled_end, + topic, message, status, response_note, created_at, updated_at + ) VALUES ( + @id, @studentId, @teacherId, @courseId, @scheduledStart, @scheduledEnd, + @topic, @message, @status, @responseNote, @createdAt, @updatedAt + ) + `) + + insert.run({ + id: DEMO_IDS.acceptedAppointment, + studentId: userIds.student, + teacherId: userIds.teacher, + courseId: DEMO_IDS.publishedCourse, + scheduledStart: '2026-07-25T10:00:00.000Z', + scheduledEnd: '2026-07-25T10:45:00.000Z', + topic: '餐厅点餐情景练习', + message: '希望重点练习忌口说明和结账表达。', + status: 'accepted', + responseNote: '已准备菜单图片和角色卡,课堂见。', + createdAt: '2026-07-16T08:00:00.000Z', + updatedAt: '2026-07-16T09:00:00.000Z' + }) + + insert.run({ + id: DEMO_IDS.pendingAppointment, + studentId: userIds.student, + teacherId: userIds.teacher, + courseId: DEMO_IDS.publishedCourse, + scheduledStart: '2026-08-01T10:00:00.000Z', + scheduledEnd: '2026-08-01T10:45:00.000Z', + topic: '城市问路与交通', + message: '想练习地铁换乘和向路人确认方向。', + status: 'pending', + responseNote: '', + createdAt: '2026-07-18T08:00:00.000Z', + updatedAt: '2026-07-18T08:00:00.000Z' + }) + + database + .prepare( + ` + INSERT OR IGNORE INTO classrooms ( + id, appointment_id, room_code, status, created_at, updated_at + ) VALUES (?, ?, ?, 'scheduled', ?, ?) + ` + ) + .run( + DEMO_IDS.classroom, + DEMO_IDS.acceptedAppointment, + 'demo-classroom-01', + '2026-07-16T09:00:00.000Z', + '2026-07-16T09:00:00.000Z' + ) +} + +function insertAssignments(database, userIds) { + database + .prepare( + ` + INSERT OR IGNORE INTO assignments ( + id, course_id, teacher_id, title, instructions, due_at, max_score, + status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 100, 'published', ?, ?) + ` + ) + .run( + DEMO_IDS.assignment, + DEMO_IDS.publishedCourse, + userIds.teacher, + '第一单元:生活场景表达', + '完成选择题,并用两到三句话介绍一次点餐经历。', + '2026-08-05T15:59:59.000Z', + '2026-07-12T02:00:00.000Z', + '2026-07-12T02:00:00.000Z' + ) + + const insertQuestion = database.prepare(` + INSERT OR IGNORE INTO assignment_questions ( + id, assignment_id, position, question_type, prompt, options_json, + correct_answer_json, points, explanation + ) VALUES ( + @id, @assignmentId, @position, @questionType, @prompt, @optionsJson, + @correctAnswerJson, @points, @explanation + ) + `) + + insertQuestion.run({ + id: DEMO_IDS.questionChoice, + assignmentId: DEMO_IDS.assignment, + position: 1, + questionType: 'single_choice', + prompt: '服务员问“您想喝点儿什么?”时,哪一个回答最自然?', + optionsJson: JSON.stringify([ + '我喝了茶', + '请给我一杯茶', + '茶在桌子上', + '他喜欢喝茶' + ]), + correctAnswerJson: JSON.stringify('请给我一杯茶'), + points: 40, + explanation: '“请给我……”可以礼貌地提出点单需求。' + }) + + insertQuestion.run({ + id: DEMO_IDS.questionText, + assignmentId: DEMO_IDS.assignment, + position: 2, + questionType: 'text', + prompt: '请用两到三句话介绍一次点餐经历。', + optionsJson: null, + correctAnswerJson: null, + points: 60, + explanation: '注意使用时间、菜名和感受等信息。' + }) + + database + .prepare( + ` + INSERT OR IGNORE INTO submissions ( + id, assignment_id, student_id, answers_json, status, submitted_at, + score, feedback, graded_by, graded_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'graded', ?, ?, ?, ?, ?, ?, ?) + ` + ) + .run( + DEMO_IDS.submission, + DEMO_IDS.assignment, + userIds.student, + JSON.stringify({ + [DEMO_IDS.questionChoice]: '请给我一杯茶', + [DEMO_IDS.questionText]: + '上周我去了一家川菜馆。我点了鱼香肉丝,味道很好,但是有一点儿辣。' + }), + '2026-07-14T10:00:00.000Z', + 92, + '表达完整自然;可以继续练习“少放辣椒”等具体要求。', + userIds.teacher, + '2026-07-15T08:30:00.000Z', + '2026-07-14T09:45:00.000Z', + '2026-07-15T08:30:00.000Z' + ) +} + +function insertSupportingData(database, userIds) { + database + .prepare( + ` + INSERT OR IGNORE INTO chat_messages ( + id, classroom_id, sender_id, message_type, content, metadata_json, + client_message_id, created_at + ) VALUES (?, ?, ?, 'text', ?, '{}', ?, ?) + ` + ) + .run( + DEMO_IDS.message, + DEMO_IDS.classroom, + userIds.teacher, + '欢迎来到演示课堂,我们将先从菜单词汇开始。', + 'seed-welcome-message', + '2026-07-16T09:01:00.000Z' + ) + + const insertNotification = database.prepare(` + INSERT OR IGNORE INTO notifications ( + id, user_id, type, title, body, resource_type, resource_id, link, + dedupe_key, created_at + ) VALUES ( + @id, @userId, @type, @title, @body, @resourceType, @resourceId, @link, + @dedupeKey, @createdAt + ) + `) + + insertNotification.run({ + id: DEMO_IDS.studentNotification, + userId: userIds.student, + type: 'appointment.accepted', + title: '预约已接受', + body: '王老师已接受“餐厅点餐情景练习”预约。', + resourceType: 'appointment', + resourceId: DEMO_IDS.acceptedAppointment, + link: `/student/liveClass?classroomId=${DEMO_IDS.classroom}`, + dedupeKey: `appointment:${DEMO_IDS.acceptedAppointment}:accepted:student`, + createdAt: '2026-07-16T09:00:00.000Z' + }) + + insertNotification.run({ + id: DEMO_IDS.teacherNotification, + userId: userIds.teacher, + type: 'appointment.requested', + title: '新的课程预约', + body: '林晓雨预约了“城市问路与交通”练习。', + resourceType: 'appointment', + resourceId: DEMO_IDS.pendingAppointment, + link: '/teacher/teachingDocking', + dedupeKey: `appointment:${DEMO_IDS.pendingAppointment}:requested:teacher`, + createdAt: '2026-07-18T08:00:00.000Z' + }) + + database + .prepare( + ` + INSERT OR IGNORE INTO audit_logs ( + id, actor_id, action, entity_type, entity_id, details_json, created_at + ) VALUES (?, ?, 'course.approved', 'course', ?, ?, ?) + ` + ) + .run( + DEMO_IDS.auditLog, + userIds.administrator, + DEMO_IDS.publishedCourse, + JSON.stringify({ + source: 'demo-seed', + previousStatus: 'pending', + nextStatus: 'published' + }), + '2026-04-08T09:30:00.000Z' + ) +} + +export function seedDatabase(database) { + if (!database || typeof database.prepare !== 'function') { + throw new TypeError('seedDatabase requires an open better-sqlite3 database') + } + + const seed = database.transaction(() => { + const userIds = insertUsers(database) + insertTeacherProfile(database, userIds) + insertCourses(database, userIds) + insertAppointments(database, userIds) + insertAssignments(database, userIds) + insertSupportingData(database, userIds) + }) + + seed() + + return { + users: database.prepare('SELECT COUNT(*) AS count FROM users').get().count, + courses: database.prepare('SELECT COUNT(*) AS count FROM courses').get() + .count, + appointments: database + .prepare('SELECT COUNT(*) AS count FROM appointments') + .get().count, + assignments: database + .prepare('SELECT COUNT(*) AS count FROM assignments') + .get().count, + submissions: database + .prepare('SELECT COUNT(*) AS count FROM submissions') + .get().count + } +} diff --git a/server/db/seed.js b/server/db/seed.js new file mode 100644 index 0000000..00d7296 --- /dev/null +++ b/server/db/seed.js @@ -0,0 +1,39 @@ +import { resolve } from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +import { createDatabase, DEFAULT_DATABASE_FILE } from './database.js' +import { + DEMO_ACCOUNTS, + DEMO_IDS, + DEMO_PASSWORD, + hashPassword, + seedDatabase, + verifyPasswordHash +} from './seed-data.js' + +export { + DEMO_ACCOUNTS, + DEMO_IDS, + DEMO_PASSWORD, + hashPassword, + seedDatabase, + verifyPasswordHash +} + +function isMainModule() { + return ( + Boolean(process.argv[1]) && + pathToFileURL(resolve(process.argv[1])).href === import.meta.url + ) +} + +if (isMainModule()) { + const filename = process.argv[2] || DEFAULT_DATABASE_FILE + const database = createDatabase({ filename }) + const result = seedDatabase(database) + database.close() + process.stdout.write( + `Demo data seeded: ${filename} (${result.users} users, ${result.courses} courses)\n` + ) +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..0f0f7f6 --- /dev/null +++ b/server/index.js @@ -0,0 +1,51 @@ +/* global process */ + +import { buildApp } from './app.js' +import { loadConfig } from './config.js' +import { createDatabase } from './db/database.js' + +const config = loadConfig() +let app +let database +let shuttingDown = false + +async function shutdown(signal) { + if (shuttingDown) return + shuttingDown = true + + app?.log.info({ signal }, 'Shutting down API server') + + try { + if (app) await app.close() + } finally { + if (database?.open) database.close() + } +} + +try { + database = createDatabase({ + filename: config.databasePath, + seed: config.seedOnStart + }) + app = await buildApp({ database, config }) + await app.listen({ host: config.host, port: config.port }) + + for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, () => { + shutdown(signal).catch((error) => { + app.log.error({ err: error }, 'Graceful shutdown failed') + process.exitCode = 1 + }) + }) + } +} catch (error) { + if (app) { + app.log.error({ err: error }, 'API server failed to start') + await app.close().catch(() => {}) + } else { + console.error('API server failed to start') + } + + if (database?.open) database.close() + process.exitCode = 1 +} diff --git a/server/lib/http.js b/server/lib/http.js new file mode 100644 index 0000000..3a0dbc4 --- /dev/null +++ b/server/lib/http.js @@ -0,0 +1,91 @@ +export class HttpError extends Error { + constructor(statusCode, code, message, data = null, options = {}) { + super(message, options) + this.name = 'HttpError' + this.statusCode = statusCode + this.code = code + this.data = data + } +} + +export function envelope(code, msg, data = null) { + return { + code, + msg, + data: data === undefined ? null : data + } +} + +export function success(data = null, msg = 'ok', code = 0) { + return envelope(code, msg, data) +} + +export function failure(code, msg, data = null) { + return envelope(code, msg, data) +} + +export function sendSuccess(reply, data = null, options = {}) { + const { statusCode = 200, code = 0, msg = 'ok' } = options + return reply.code(statusCode).send(success(data, msg, code)) +} + +export function sendFailure(reply, options = {}) { + const { + statusCode = 500, + code = 'INTERNAL_ERROR', + msg = 'Internal server error', + data = null + } = options + + return reply.code(statusCode).send(failure(code, msg, data)) +} + +export function createHttpError( + statusCode, + code, + message, + data = null, + options = {} +) { + return new HttpError(statusCode, code, message, data, options) +} + +export function errorDescriptor(error) { + if (error instanceof HttpError) { + return { + statusCode: error.statusCode, + code: error.code, + msg: error.message, + data: error.data + } + } + + if (error?.validation) { + return { + statusCode: 400, + code: 'VALIDATION_ERROR', + msg: 'Invalid request', + data: null + } + } + + if ( + Number.isInteger(error?.statusCode) && + error.statusCode >= 400 && + error.statusCode < 500 + ) { + return { + statusCode: error.statusCode, + code: error.code || 'REQUEST_ERROR', + msg: error.statusCode === 404 ? 'Route not found' : 'Request failed', + data: null + } + } + + return { + statusCode: 500, + code: 'INTERNAL_ERROR', + msg: 'Internal server error', + data: null + } +} diff --git a/server/lib/password.js b/server/lib/password.js new file mode 100644 index 0000000..6bb492b --- /dev/null +++ b/server/lib/password.js @@ -0,0 +1,139 @@ +import { Buffer } from 'node:buffer' +import { randomBytes, scrypt as nodeScrypt, timingSafeEqual } from 'node:crypto' + +const SCRYPT_PREFIX = 'scrypt' +const SCRYPT_COST = 16384 +const SCRYPT_BLOCK_SIZE = 8 +const SCRYPT_PARALLELIZATION = 1 +const SCRYPT_KEY_LENGTH = 64 +const SCRYPT_MAX_MEMORY = 64 * 1024 * 1024 +const SALT_BYTES = 16 + +function deriveKey(password, salt, keyLength, options) { + return new Promise((resolve, reject) => { + nodeScrypt(password, salt, keyLength, options, (error, derivedKey) => { + if (error) { + reject(error) + return + } + + resolve(derivedKey) + }) + }) +} + +function parsePasswordHash(encodedHash) { + if (typeof encodedHash !== 'string') { + return null + } + + const parts = encodedHash.split('$') + if (parts.length !== 7 || parts[0] !== SCRYPT_PREFIX) { + return null + } + + const [, costValue, blockSizeValue, parallelizationValue, keyLengthValue] = + parts + const cost = Number(costValue) + const blockSize = Number(blockSizeValue) + const parallelization = Number(parallelizationValue) + const keyLength = Number(keyLengthValue) + + if ( + !Number.isSafeInteger(cost) || + !Number.isSafeInteger(blockSize) || + !Number.isSafeInteger(parallelization) || + !Number.isSafeInteger(keyLength) || + cost < 2 || + cost > 1048576 || + (cost & (cost - 1)) !== 0 || + blockSize < 1 || + blockSize > 32 || + parallelization < 1 || + parallelization > 16 || + keyLength < 32 || + keyLength > 128 + ) { + return null + } + + try { + const salt = Buffer.from(parts[5], 'base64url') + const digest = Buffer.from(parts[6], 'base64url') + + if (salt.length < 16 || digest.length !== keyLength) { + return null + } + + return { + cost, + blockSize, + parallelization, + keyLength, + salt, + digest + } + } catch { + return null + } +} + +export async function hashPassword(password) { + if (typeof password !== 'string' || password.length === 0) { + throw new TypeError('Password must be a non-empty string') + } + + const salt = randomBytes(SALT_BYTES) + const digest = await deriveKey(password, salt, SCRYPT_KEY_LENGTH, { + N: SCRYPT_COST, + r: SCRYPT_BLOCK_SIZE, + p: SCRYPT_PARALLELIZATION, + maxmem: SCRYPT_MAX_MEMORY + }) + + return [ + SCRYPT_PREFIX, + SCRYPT_COST, + SCRYPT_BLOCK_SIZE, + SCRYPT_PARALLELIZATION, + SCRYPT_KEY_LENGTH, + salt.toString('base64url'), + digest.toString('base64url') + ].join('$') +} + +export async function verifyPassword(password, encodedHash) { + if (typeof password !== 'string') { + return false + } + + const parsed = parsePasswordHash(encodedHash) + if (!parsed) { + return false + } + + try { + const candidate = await deriveKey(password, parsed.salt, parsed.keyLength, { + N: parsed.cost, + r: parsed.blockSize, + p: parsed.parallelization, + maxmem: SCRYPT_MAX_MEMORY + }) + + return timingSafeEqual(candidate, parsed.digest) + } catch { + return false + } +} + +export function needsPasswordRehash(encodedHash) { + const parsed = parsePasswordHash(encodedHash) + + return ( + !parsed || + parsed.cost !== SCRYPT_COST || + parsed.blockSize !== SCRYPT_BLOCK_SIZE || + parsed.parallelization !== SCRYPT_PARALLELIZATION || + parsed.keyLength !== SCRYPT_KEY_LENGTH + ) +} diff --git a/server/lib/session.js b/server/lib/session.js new file mode 100644 index 0000000..25d8fd7 --- /dev/null +++ b/server/lib/session.js @@ -0,0 +1,241 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto' + +export const SESSION_COOKIE_NAME = 'icp_session' +export const DEFAULT_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7 + +function nowIso() { + return new Date().toISOString() +} + +export function generateSessionToken() { + return randomBytes(32).toString('base64url') +} + +export function hashSessionToken(token) { + if (typeof token !== 'string' || token.length < 32 || token.length > 256) { + return null + } + + return createHash('sha256').update(token, 'utf8').digest('hex') +} + +export function publicUser(user) { + if (!user) { + return null + } + + return { + id: user.id ?? user.user_id, + email: user.email, + role: user.role, + displayName: user.display_name ?? user.displayName ?? '', + avatarUrl: user.avatar_url ?? user.avatarUrl ?? null, + country: user.country ?? null, + region: user.region ?? null, + age: user.age ?? null, + chineseLevel: user.chinese_level ?? user.chineseLevel ?? null, + bio: user.bio ?? '', + status: user.status, + createdAt: user.created_at ?? user.createdAt, + updatedAt: user.updated_at ?? user.updatedAt + } +} + +export function createSession( + db, + userId, + { + ttlSeconds = DEFAULT_SESSION_TTL_SECONDS, + userAgent = null, + ipAddress = null + } = {} +) { + if (!db || typeof db.prepare !== 'function') { + throw new TypeError('A database connection is required') + } + + if (typeof userId !== 'string' || userId.length === 0) { + throw new TypeError('A user id is required') + } + + const boundedTtl = Math.max( + 60, + Math.min( + Number(ttlSeconds) || DEFAULT_SESSION_TTL_SECONDS, + 60 * 60 * 24 * 30 + ) + ) + const id = randomUUID() + const token = generateSessionToken() + const tokenHash = hashSessionToken(token) + const createdAt = nowIso() + const expiresAt = new Date(Date.now() + boundedTtl * 1000).toISOString() + + db.prepare( + `INSERT INTO sessions ( + id, + user_id, + token_hash, + expires_at, + created_at, + last_seen_at, + user_agent, + ip_address + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + userId, + tokenHash, + expiresAt, + createdAt, + createdAt, + userAgent?.slice(0, 512) ?? null, + ipAddress?.slice(0, 64) ?? null + ) + + return { + id, + userId, + token, + expiresAt, + ttlSeconds: boundedTtl + } +} + +export function findSessionByToken(db, token, { touch = true } = {}) { + const tokenHash = hashSessionToken(token) + if (!tokenHash) { + return null + } + + const currentTime = nowIso() + const row = db + .prepare( + `SELECT + s.id AS session_id, + s.user_id AS session_user_id, + s.expires_at AS session_expires_at, + s.last_seen_at AS session_last_seen_at, + u.id, + u.email, + u.role, + u.display_name, + u.avatar_url, + u.country, + u.region, + u.age, + u.chinese_level, + u.bio, + u.status, + u.created_at, + u.updated_at + FROM sessions AS s + INNER JOIN users AS u ON u.id = s.user_id + WHERE s.token_hash = ? + AND s.revoked_at IS NULL + AND s.expires_at > ? + AND u.status = 'active' + LIMIT 1` + ) + .get(tokenHash, currentTime) + + if (!row) { + return null + } + + if (touch) { + db.prepare('UPDATE sessions SET last_seen_at = ? WHERE id = ?').run( + currentTime, + row.session_id + ) + } + + return { + sessionId: row.session_id, + userId: row.session_user_id, + expiresAt: row.session_expires_at, + user: publicUser(row) + } +} + +export function revokeSession(db, sessionId) { + if (!sessionId) { + return false + } + + const result = db + .prepare( + `UPDATE sessions + SET revoked_at = ? + WHERE id = ? AND revoked_at IS NULL` + ) + .run(nowIso(), sessionId) + + return result.changes > 0 +} + +export function revokeSessionByToken(db, token) { + const tokenHash = hashSessionToken(token) + if (!tokenHash) { + return false + } + + const result = db + .prepare( + `UPDATE sessions + SET revoked_at = ? + WHERE token_hash = ? AND revoked_at IS NULL` + ) + .run(nowIso(), tokenHash) + + return result.changes > 0 +} + +export function revokeUserSessions( + db, + userId, + { exceptSessionId = null } = {} +) { + const revokedAt = nowIso() + + if (exceptSessionId) { + return db + .prepare( + `UPDATE sessions + SET revoked_at = ? + WHERE user_id = ? AND id <> ? AND revoked_at IS NULL` + ) + .run(revokedAt, userId, exceptSessionId).changes + } + + return db + .prepare( + `UPDATE sessions + SET revoked_at = ? + WHERE user_id = ? AND revoked_at IS NULL` + ) + .run(revokedAt, userId).changes +} + +export function extractSessionToken(request, { allowBearer = true } = {}) { + const cookieToken = request.cookies?.[SESSION_COOKIE_NAME] + if (typeof cookieToken === 'string' && cookieToken.length > 0) { + return { token: cookieToken, source: 'cookie' } + } + + if (!allowBearer) { + return null + } + + const authorization = request.headers?.authorization + if (typeof authorization !== 'string') { + return null + } + + const match = /^Bearer\s+([^\s]+)$/i.exec(authorization) + if (!match) { + return null + } + + return { token: match[1], source: 'bearer' } +} diff --git a/server/plugins/auth.js b/server/plugins/auth.js new file mode 100644 index 0000000..426f25f --- /dev/null +++ b/server/plugins/auth.js @@ -0,0 +1,129 @@ +import process from 'node:process' + +import { + DEFAULT_SESSION_TTL_SECONDS, + extractSessionToken, + findSessionByToken, + SESSION_COOKIE_NAME +} from '../lib/session.js' + +function sendError(reply, statusCode, message, data = null) { + return reply.code(statusCode).send({ + code: statusCode, + msg: message, + data + }) +} + +export async function authPlugin(app, options = {}) { + const db = options.db ?? app.db + if (!db) { + throw new Error('authPlugin requires a database connection') + } + + const allowBearer = options.allowBearer !== false + const sessionTtlSeconds = + Number(options.sessionTtlSeconds) || DEFAULT_SESSION_TTL_SECONDS + const appOrigin = options.appOrigin ?? app.config?.appOrigin ?? null + const secureCookies = + options.secureCookies ?? process.env.NODE_ENV === 'production' + + if (!app.hasRequestDecorator('auth')) { + app.decorateRequest('auth', null) + } + + app.addHook('preHandler', async (request, reply) => { + if (['GET', 'HEAD', 'OPTIONS'].includes(request.method)) { + return + } + + const hasSessionCookie = Boolean(request.cookies?.[SESSION_COOKIE_NAME]) + const origin = request.headers.origin + if (hasSessionCookie && origin && appOrigin && origin !== appOrigin) { + return sendError(reply, 403, '请求来源不受信任') + } + }) + + const authenticate = async (request, reply) => { + const credential = extractSessionToken(request, { allowBearer }) + if (!credential) { + return sendError(reply, 401, '请先登录') + } + + const session = findSessionByToken(db, credential.token) + if (!session) { + if (credential.source === 'cookie') { + reply.clearCookie(SESSION_COOKIE_NAME, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: secureCookies + }) + } + + return sendError(reply, 401, '登录状态已失效,请重新登录') + } + + request.auth = { + ...session, + credentialSource: credential.source + } + } + + const requireRole = (...allowedRoles) => { + const roles = new Set(allowedRoles.flat()) + + return async (request, reply) => { + if (!request.auth) { + await authenticate(request, reply) + } + + if (reply.sent) { + return + } + + if (!roles.has(request.auth.user.role)) { + return sendError(reply, 403, '没有权限执行此操作') + } + } + } + + const requireOwnership = (resolveOwnerId, privilegedRoles = []) => { + const privileged = new Set(privilegedRoles) + + return async (request, reply) => { + if (!request.auth) { + await authenticate(request, reply) + } + + if (reply.sent) { + return + } + + if (privileged.has(request.auth.user.role)) { + return + } + + const ownerId = await resolveOwnerId(request) + if (!ownerId || ownerId !== request.auth.user.id) { + return sendError(reply, 403, '没有权限访问该资源') + } + } + } + + const sessionCookieOptions = (ttlSeconds = sessionTtlSeconds) => ({ + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: secureCookies, + maxAge: ttlSeconds + }) + + app.decorate('authenticate', authenticate) + app.decorate('requireRole', requireRole) + app.decorate('requireOwnership', requireOwnership) + app.decorate('sessionCookieOptions', sessionCookieOptions) + app.decorate('sessionTtlSeconds', sessionTtlSeconds) +} + +export default authPlugin diff --git a/server/routes/admin.js b/server/routes/admin.js new file mode 100644 index 0000000..061d528 --- /dev/null +++ b/server/routes/admin.js @@ -0,0 +1,747 @@ +import { randomUUID } from 'node:crypto' + +import { z } from 'zod' + +import { courseFromRow, reviewFromRow } from './courses.js' + +const courseStatusSchema = z.enum([ + 'draft', + 'pending', + 'published', + 'rejected', + 'archived' +]) + +const reviewQueueQuerySchema = z + .object({ + page: z.coerce.number().int().min(1).max(100000).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(12), + status: courseStatusSchema.default('pending'), + search: z.string().trim().max(100).optional() + }) + .strict() + +const courseIdParamsSchema = z.object({ + courseId: z.string().uuid('课程 ID 格式不正确') +}) + +const reviewActionSchema = z + .object({ + action: z.enum(['approve', 'reject']), + note: z.string().trim().max(2000).default('') + }) + .strict() + .superRefine((value, context) => { + if (value.action === 'reject' && value.note.length === 0) { + context.addIssue({ + code: 'custom', + path: ['note'], + message: '驳回课程时必须填写审核意见' + }) + } + }) + +const teacherVerificationQuerySchema = z + .object({ + page: z.coerce.number().int().min(1).max(100000).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(12), + status: z.enum(['pending', 'verified']).default('pending'), + search: z.string().trim().max(100).optional() + }) + .strict() + +const teacherIdParamsSchema = z.object({ + teacherId: z.string().uuid('教师 ID 格式不正确') +}) + +const teacherVerificationActionSchema = z + .object({ + action: z.enum(['approve', 'revoke']), + note: z.string().trim().max(2000).default('') + }) + .strict() + .superRefine((value, context) => { + if (value.action === 'revoke' && value.note.length === 0) { + context.addIssue({ + code: 'custom', + path: ['note'], + message: '撤销教师认证时必须填写原因' + }) + } + }) + +const adminCourseColumns = ` + c.*, + u.display_name AS teacher_name, + u.avatar_url AS teacher_avatar_url, + tp.school AS teacher_school, + tp.title AS teacher_title, + tp.rating AS teacher_rating +` + +const adminCourseFrom = ` + FROM courses AS c + INNER JOIN users AS u ON u.id = c.teacher_id + LEFT JOIN teacher_profiles AS tp ON tp.user_id = c.teacher_id +` + +const adminCourseSelect = `SELECT ${adminCourseColumns} ${adminCourseFrom}` + +function responseData(reply, data, message = '操作成功', statusCode = 200) { + return reply.code(statusCode).send({ code: 0, msg: message, data }) +} + +function responseError(reply, statusCode, message, data = null) { + return reply.code(statusCode).send({ + code: statusCode, + msg: message, + data + }) +} + +function validationError(reply, result) { + return responseError(reply, 400, '请求参数不正确', { + errors: result.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message + })) + }) +} + +function unwrapBody(body) { + if (body && typeof body === 'object' && body.data) { + return body.data + } + + return body ?? {} +} + +function escapeLike(value) { + return value.replace(/[\\%_]/g, '\\$&') +} + +function pagination(page, pageSize, total) { + return { + page, + pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / pageSize) + } +} + +function queueItemFromRow(row) { + const latestReview = row.latest_review_id + ? reviewFromRow({ + id: row.latest_review_id, + course_id: row.id, + reviewer_id: row.latest_reviewer_id, + decision: row.latest_review_decision, + review_note: row.latest_review_note, + created_at: row.latest_review_created_at + }) + : null + + return { + course: courseFromRow(row), + submittedAt: row.updated_at, + latestReview + } +} + +function parseJsonObject(value) { + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed + : {} + } catch { + return {} + } +} + +function parseJsonArray(value) { + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +const adminTeacherSelect = ` + SELECT + u.id, + u.email, + u.display_name, + u.avatar_url, + u.country, + u.region, + u.status AS account_status, + u.created_at, + u.updated_at, + tp.school, + tp.title, + tp.experience_years, + tp.specialties_json, + tp.certificates_json, + tp.languages_json, + tp.verified_at, + tp.updated_at AS profile_updated_at + FROM users AS u + LEFT JOIN teacher_profiles AS tp ON tp.user_id = u.id +` + +function teacherVerificationFromRow(row) { + return { + id: row.id, + email: row.email, + displayName: row.display_name, + avatarUrl: row.avatar_url ?? null, + country: row.country ?? null, + region: row.region ?? null, + accountStatus: row.account_status, + school: row.school ?? '', + title: row.title ?? '', + experienceYears: Number(row.experience_years ?? 0), + specialties: parseJsonArray(row.specialties_json), + certificates: parseJsonArray(row.certificates_json), + languages: parseJsonArray(row.languages_json), + verificationStatus: row.verified_at ? 'verified' : 'pending', + verifiedAt: row.verified_at ?? null, + registeredAt: row.created_at, + updatedAt: row.profile_updated_at ?? row.updated_at + } +} + +export async function adminRoutes(app) { + const db = app.db + if (!db) { + throw new Error('adminRoutes requires app.db') + } + + app.get( + '/api/v1/admin/course-reviews', + { preHandler: app.requireRole('administrator') }, + async (request, reply) => { + const result = reviewQueueQuerySchema.safeParse(request.query ?? {}) + if (!result.success) { + return validationError(reply, result) + } + + const { page, pageSize, status, search } = result.data + const where = ['c.status = ?'] + const parameters = [status] + if (search) { + const pattern = `%${escapeLike(search)}%` + where.push( + "(c.title LIKE ? ESCAPE '\\' OR c.summary LIKE ? ESCAPE '\\' OR u.display_name LIKE ? ESCAPE '\\')" + ) + parameters.push(pattern, pattern, pattern) + } + + const whereClause = `WHERE ${where.join(' AND ')}` + const total = db + .prepare( + `SELECT COUNT(*) AS count + FROM courses AS c + INNER JOIN users AS u ON u.id = c.teacher_id + ${whereClause}` + ) + .get(...parameters).count + const rows = db + .prepare( + `SELECT ${adminCourseColumns}, + ( + SELECT cr.id FROM course_reviews AS cr + WHERE cr.course_id = c.id + ORDER BY cr.created_at DESC, cr.id DESC LIMIT 1 + ) AS latest_review_id, + ( + SELECT cr.reviewer_id FROM course_reviews AS cr + WHERE cr.course_id = c.id + ORDER BY cr.created_at DESC, cr.id DESC LIMIT 1 + ) AS latest_reviewer_id, + ( + SELECT cr.decision FROM course_reviews AS cr + WHERE cr.course_id = c.id + ORDER BY cr.created_at DESC, cr.id DESC LIMIT 1 + ) AS latest_review_decision, + ( + SELECT cr.review_note FROM course_reviews AS cr + WHERE cr.course_id = c.id + ORDER BY cr.created_at DESC, cr.id DESC LIMIT 1 + ) AS latest_review_note, + ( + SELECT cr.created_at FROM course_reviews AS cr + WHERE cr.course_id = c.id + ORDER BY cr.created_at DESC, cr.id DESC LIMIT 1 + ) AS latest_review_created_at + ${adminCourseFrom} + ${whereClause} + ORDER BY c.updated_at ASC, c.id ASC + LIMIT ? OFFSET ?` + ) + .all(...parameters, pageSize, (page - 1) * pageSize) + + return responseData(reply, { + items: rows.map(queueItemFromRow), + pagination: pagination(page, pageSize, total) + }) + } + ) + + app.post( + '/api/v1/admin/course-reviews/:courseId', + { preHandler: app.requireRole('administrator') }, + async (request, reply) => { + const paramsResult = courseIdParamsSchema.safeParse(request.params) + if (!paramsResult.success) { + return validationError(reply, paramsResult) + } + + const bodyResult = reviewActionSchema.safeParse(unwrapBody(request.body)) + if (!bodyResult.success) { + return validationError(reply, bodyResult) + } + + const courseId = paramsResult.data.courseId + const { action, note } = bodyResult.data + const nextStatus = action === 'approve' ? 'published' : 'rejected' + const decision = action === 'approve' ? 'approved' : 'rejected' + const reviewedAt = new Date().toISOString() + const reviewId = randomUUID() + const notificationId = randomUUID() + const auditId = randomUUID() + + const reviewCourse = db.transaction(() => { + const course = db + .prepare( + `SELECT c.*, u.status AS teacher_account_status, + tp.verified_at AS teacher_verified_at + FROM courses AS c + INNER JOIN users AS u ON u.id = c.teacher_id + LEFT JOIN teacher_profiles AS tp ON tp.user_id = c.teacher_id + WHERE c.id = ? + LIMIT 1` + ) + .get(courseId) + + if (!course) { + throw new Error('COURSE_NOT_FOUND') + } + if (course.status !== 'pending') { + const error = new Error('COURSE_NOT_PENDING') + error.currentStatus = course.status + throw error + } + if ( + action === 'approve' && + (course.teacher_account_status !== 'active' || + !course.teacher_verified_at) + ) { + throw new Error('COURSE_TEACHER_NOT_VERIFIED') + } + + const updateResult = db + .prepare( + `UPDATE courses + SET status = ?, rejection_reason = ?, published_at = ?, updated_at = ? + WHERE id = ? AND status = 'pending'` + ) + .run( + nextStatus, + action === 'reject' ? note : null, + action === 'approve' ? reviewedAt : null, + reviewedAt, + courseId + ) + + if (updateResult.changes !== 1) { + throw new Error('COURSE_STATE_CHANGED') + } + + db.prepare( + `INSERT INTO course_reviews ( + id, course_id, reviewer_id, decision, review_note, created_at + ) VALUES (?, ?, ?, ?, ?, ?)` + ).run( + reviewId, + courseId, + request.auth.user.id, + decision, + note, + reviewedAt + ) + + db.prepare( + `INSERT INTO notifications ( + id, user_id, type, title, body, resource_type, resource_id, link, + dedupe_key, created_at + ) VALUES (?, ?, ?, ?, ?, 'course', ?, ?, ?, ?)` + ).run( + notificationId, + course.teacher_id, + `course.${decision}`, + action === 'approve' ? '课程审核已通过' : '课程审核未通过', + action === 'approve' + ? `课程「${course.title}」已发布,学生现在可以查看。` + : `课程「${course.title}」需要修改:${note}`, + courseId, + `/teacher/onlineCourses?courseId=${courseId}`, + `course:${courseId}:review:${reviewId}`, + reviewedAt + ) + + db.prepare( + `INSERT INTO audit_logs ( + id, actor_id, action, entity_type, entity_id, details_json, + request_id, ip_address, user_agent, created_at + ) VALUES (?, ?, ?, 'course', ?, ?, ?, ?, ?, ?)` + ).run( + auditId, + request.auth.user.id, + `course.${decision}`, + courseId, + JSON.stringify({ + previousStatus: 'pending', + nextStatus, + reviewId, + note + }), + request.id ?? null, + request.ip ?? null, + request.headers['user-agent'] ?? null, + reviewedAt + ) + }) + + try { + reviewCourse() + } catch (error) { + if (error?.message === 'COURSE_NOT_FOUND') { + return responseError(reply, 404, '课程不存在') + } + if (error?.message === 'COURSE_NOT_PENDING') { + return responseError(reply, 409, '只能审核待审核状态的课程', { + currentStatus: error.currentStatus, + requiredStatus: 'pending' + }) + } + if (error?.message === 'COURSE_TEACHER_NOT_VERIFIED') { + return responseError( + reply, + 409, + '课程教师当前未通过平台认证,不能批准发布' + ) + } + if (error?.message === 'COURSE_STATE_CHANGED') { + return responseError(reply, 409, '课程状态已变化,请刷新后重试') + } + throw error + } + + const course = db + .prepare(`${adminCourseSelect} WHERE c.id = ? LIMIT 1`) + .get(courseId) + const review = db + .prepare('SELECT * FROM course_reviews WHERE id = ? LIMIT 1') + .get(reviewId) + + return responseData( + reply, + { + course: courseFromRow(course), + review: reviewFromRow(review) + }, + action === 'approve' ? '课程已审核通过' : '课程已驳回' + ) + } + ) + + app.get( + '/api/v1/admin/teacher-verifications', + { preHandler: app.requireRole('administrator') }, + async (request, reply) => { + const result = teacherVerificationQuerySchema.safeParse( + request.query ?? {} + ) + if (!result.success) { + return validationError(reply, result) + } + + const { page, pageSize, status, search } = result.data + const where = ["u.role = 'teacher'", "u.status = 'active'"] + const parameters = [] + where.push( + status === 'verified' + ? 'tp.verified_at IS NOT NULL' + : 'tp.verified_at IS NULL' + ) + if (search) { + const pattern = `%${escapeLike(search)}%` + where.push( + "(u.display_name LIKE ? ESCAPE '\\' OR u.email LIKE ? ESCAPE '\\' OR COALESCE(tp.school, '') LIKE ? ESCAPE '\\' OR COALESCE(tp.title, '') LIKE ? ESCAPE '\\')" + ) + parameters.push(pattern, pattern, pattern, pattern) + } + + const whereClause = `WHERE ${where.join(' AND ')}` + const total = db + .prepare( + `SELECT COUNT(*) AS count + FROM users AS u + LEFT JOIN teacher_profiles AS tp ON tp.user_id = u.id + ${whereClause}` + ) + .get(...parameters).count + const rows = db + .prepare( + `${adminTeacherSelect} + ${whereClause} + ORDER BY COALESCE(tp.updated_at, u.updated_at) ASC, u.id ASC + LIMIT ? OFFSET ?` + ) + .all(...parameters, pageSize, (page - 1) * pageSize) + + return responseData(reply, { + items: rows.map(teacherVerificationFromRow), + pagination: pagination(page, pageSize, total) + }) + } + ) + + app.post( + '/api/v1/admin/teacher-verifications/:teacherId', + { preHandler: app.requireRole('administrator') }, + async (request, reply) => { + const paramsResult = teacherIdParamsSchema.safeParse(request.params) + if (!paramsResult.success) { + return validationError(reply, paramsResult) + } + + const bodyResult = teacherVerificationActionSchema.safeParse( + unwrapBody(request.body) + ) + if (!bodyResult.success) { + return validationError(reply, bodyResult) + } + + const teacherId = paramsResult.data.teacherId + const { action, note } = bodyResult.data + const decidedAt = new Date().toISOString() + const auditId = randomUUID() + const notificationId = randomUUID() + const nextStatus = action === 'approve' ? 'verified' : 'pending' + + const decideVerification = db.transaction(() => { + const teacher = db + .prepare( + `SELECT u.id, u.status, u.display_name, tp.verified_at + FROM users AS u + LEFT JOIN teacher_profiles AS tp ON tp.user_id = u.id + WHERE u.id = ? AND u.role = 'teacher' + LIMIT 1` + ) + .get(teacherId) + + if (!teacher) { + throw new Error('TEACHER_NOT_FOUND') + } + if (teacher.status !== 'active') { + throw new Error('TEACHER_DISABLED') + } + + const currentStatus = teacher.verified_at ? 'verified' : 'pending' + if (currentStatus === nextStatus) { + const error = new Error('VERIFICATION_STATE_UNCHANGED') + error.currentStatus = currentStatus + throw error + } + + if (action === 'approve') { + db.prepare( + `INSERT INTO teacher_profiles ( + user_id, verified_at, created_at, updated_at + ) VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + verified_at = excluded.verified_at, + updated_at = excluded.updated_at` + ).run(teacherId, decidedAt, decidedAt, decidedAt) + } else { + db.prepare( + `UPDATE teacher_profiles + SET verified_at = NULL, updated_at = ? + WHERE user_id = ? AND verified_at IS NOT NULL` + ).run(decidedAt, teacherId) + } + + db.prepare( + `INSERT INTO notifications ( + id, user_id, type, title, body, resource_type, resource_id, + link, dedupe_key, created_at + ) VALUES (?, ?, ?, ?, ?, 'teacher', ?, ?, ?, ?)` + ).run( + notificationId, + teacherId, + `teacher.verification.${action === 'approve' ? 'approved' : 'revoked'}`, + action === 'approve' ? '教师身份认证已通过' : '教师身份认证已撤销', + action === 'approve' + ? `您的教师身份已通过平台认证。${note ? `审核备注:${note}` : ''}` + : `您的教师身份认证已撤销。原因:${note}`, + teacherId, + '/teacher/user', + `teacher:${teacherId}:verification:${auditId}`, + decidedAt + ) + + db.prepare( + `INSERT INTO audit_logs ( + id, actor_id, action, entity_type, entity_id, details_json, + request_id, ip_address, user_agent, created_at + ) VALUES (?, ?, ?, 'teacher', ?, ?, ?, ?, ?, ?)` + ).run( + auditId, + request.auth.user.id, + `teacher.verification.${action === 'approve' ? 'approved' : 'revoked'}`, + teacherId, + JSON.stringify({ + previousStatus: currentStatus, + nextStatus, + note + }), + request.id ?? null, + request.ip ?? null, + request.headers['user-agent'] ?? null, + decidedAt + ) + }) + + try { + decideVerification() + } catch (error) { + if (error?.message === 'TEACHER_NOT_FOUND') { + return responseError(reply, 404, '教师不存在') + } + if (error?.message === 'TEACHER_DISABLED') { + return responseError(reply, 409, '教师账号已停用,不能变更认证状态') + } + if (error?.message === 'VERIFICATION_STATE_UNCHANGED') { + return responseError(reply, 409, '教师认证状态未发生变化', { + currentStatus: error.currentStatus, + requiredAction: + error.currentStatus === 'verified' ? 'revoke' : 'approve' + }) + } + throw error + } + + const teacher = db + .prepare(`${adminTeacherSelect} WHERE u.id = ? LIMIT 1`) + .get(teacherId) + + return responseData( + reply, + teacherVerificationFromRow(teacher), + action === 'approve' ? '教师认证已通过' : '教师认证已撤销' + ) + } + ) + + app.get( + '/api/v1/admin/metrics', + { preHandler: app.requireRole('administrator') }, + async (_request, reply) => { + const users = db + .prepare( + `SELECT + COUNT(*) AS total, + COALESCE(SUM(role = 'student'), 0) AS students, + COALESCE(SUM(role = 'teacher'), 0) AS teachers, + COALESCE(SUM(role = 'administrator'), 0) AS administrators, + COALESCE(SUM(status = 'active'), 0) AS active, + COALESCE(SUM(status = 'disabled'), 0) AS disabled + FROM users` + ) + .get() + const courses = db + .prepare( + `SELECT + COUNT(*) AS total, + COALESCE(SUM(status = 'draft'), 0) AS draft, + COALESCE(SUM(status = 'pending'), 0) AS pending, + COALESCE(SUM(status = 'published'), 0) AS published, + COALESCE(SUM(status = 'rejected'), 0) AS rejected, + COALESCE(SUM(status = 'archived'), 0) AS archived + FROM courses` + ) + .get() + const learning = db + .prepare( + `SELECT + (SELECT COUNT(*) FROM appointments) AS appointments, + (SELECT COUNT(*) FROM appointments + WHERE status IN ('pending', 'accepted')) AS active_appointments, + (SELECT COUNT(*) FROM assignments) AS assignments, + (SELECT COUNT(*) FROM submissions) AS submissions, + (SELECT COUNT(*) FROM submissions + WHERE status = 'submitted') AS pending_grading` + ) + .get() + const reviews = db + .prepare( + `SELECT + COUNT(*) AS total, + COALESCE(SUM(decision = 'approved'), 0) AS approved, + COALESCE(SUM(decision = 'rejected'), 0) AS rejected + FROM course_reviews` + ) + .get() + const recentActivity = db + .prepare( + `SELECT + a.id, + a.actor_id, + a.action, + a.entity_type, + a.entity_id, + a.details_json, + a.created_at, + u.display_name AS actor_name, + u.role AS actor_role + FROM audit_logs AS a + LEFT JOIN users AS u ON u.id = a.actor_id + ORDER BY a.created_at DESC, a.id DESC + LIMIT 20` + ) + .all() + .map((row) => ({ + id: row.id, + actorId: row.actor_id ?? null, + actorName: row.actor_name ?? '系统', + actorRole: row.actor_role ?? null, + action: row.action, + entityType: row.entity_type, + entityId: row.entity_id ?? null, + details: parseJsonObject(row.details_json), + createdAt: row.created_at + })) + + return responseData(reply, { + users, + courses, + learning: { + appointments: learning.appointments, + activeAppointments: learning.active_appointments, + assignments: learning.assignments, + submissions: learning.submissions, + pendingGrading: learning.pending_grading + }, + reviews, + recentActivity, + generatedAt: new Date().toISOString() + }) + } + ) +} + +export default adminRoutes diff --git a/server/routes/appointments.js b/server/routes/appointments.js new file mode 100644 index 0000000..44e9a52 --- /dev/null +++ b/server/routes/appointments.js @@ -0,0 +1,934 @@ +import { randomBytes, randomUUID } from 'node:crypto' + +import { z } from 'zod' + +const identifierSchema = z.string().trim().min(1).max(128) +const CLASSROOM_JOIN_EARLY_MS = 30 * 60 * 1000 +const CLASSROOM_JOIN_GRACE_MS = 2 * 60 * 60 * 1000 +const dateTimeSchema = z + .string() + .trim() + .min(1) + .refine((value) => Number.isFinite(Date.parse(value)), '日期时间格式不正确') + +const createAppointmentSchema = z.object({ + teacherId: identifierSchema, + courseId: z.preprocess( + (value) => (value === '' ? null : value), + identifierSchema.nullable().optional() + ), + topic: z.string().trim().min(1).max(160), + message: z.string().trim().max(2000).default(''), + scheduledStart: dateTimeSchema, + durationMinutes: z.coerce.number().int().min(15).max(240).default(60) +}) + +const listAppointmentsSchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), + status: z + .enum(['pending', 'accepted', 'rejected', 'cancelled', 'completed']) + .optional(), + teacherId: identifierSchema.optional(), + studentId: identifierSchema.optional(), + courseId: identifierSchema.optional(), + from: dateTimeSchema.optional(), + to: dateTimeSchema.optional() +}) + +const respondSchema = z.object({ + action: z.enum(['accept', 'reject']), + note: z.string().trim().max(2000).default('') +}) + +const cancelSchema = z.object({ + reason: z.string().trim().max(500).default('') +}) + +function responseData(reply, data, message = '操作成功', statusCode = 200) { + return reply.code(statusCode).send({ + code: 0, + msg: message, + data + }) +} + +function responseError(reply, statusCode, message, data = null) { + return reply.code(statusCode).send({ + code: statusCode, + msg: message, + data + }) +} + +function validationError(reply, result) { + return responseError(reply, 400, '请求参数不正确', { + errors: result.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message + })) + }) +} + +function unwrapBody(body) { + if (body && typeof body === 'object' && body.data) { + return body.data + } + + return body ?? {} +} + +function userSummary(row, prefix) { + return { + id: row[`${prefix}_id`], + displayName: row[`${prefix}_display_name`], + avatarUrl: row[`${prefix}_avatar_url`] ?? null, + role: prefix + } +} + +function appointmentData(row) { + return { + id: row.id, + studentId: row.student_id, + teacherId: row.teacher_id, + courseId: row.course_id ?? null, + scheduledStart: row.scheduled_start, + scheduledEnd: row.scheduled_end, + durationMinutes: Math.round( + (Date.parse(row.scheduled_end) - Date.parse(row.scheduled_start)) / 60_000 + ), + topic: row.topic, + message: row.message, + status: row.status, + responseNote: row.response_note, + student: userSummary(row, 'student'), + teacher: userSummary(row, 'teacher'), + course: row.course_id + ? { + id: row.course_id, + title: row.course_title, + level: row.course_level, + category: row.course_category + } + : null, + classroom: row.classroom_id + ? { + id: row.classroom_id, + roomCode: row.room_code, + status: row.classroom_status + } + : null, + createdAt: row.created_at, + updatedAt: row.updated_at + } +} + +const appointmentSelect = ` + SELECT + a.id, + a.student_id, + a.teacher_id, + a.course_id, + a.scheduled_start, + a.scheduled_end, + a.topic, + a.message, + a.status, + a.response_note, + a.created_at, + a.updated_at, + student.display_name AS student_display_name, + student.avatar_url AS student_avatar_url, + teacher.display_name AS teacher_display_name, + teacher.avatar_url AS teacher_avatar_url, + c.title AS course_title, + c.level AS course_level, + c.category AS course_category, + classroom.id AS classroom_id, + classroom.room_code, + classroom.status AS classroom_status + FROM appointments AS a + INNER JOIN users AS student ON student.id = a.student_id + INNER JOIN users AS teacher ON teacher.id = a.teacher_id + LEFT JOIN courses AS c ON c.id = a.course_id + LEFT JOIN classrooms AS classroom ON classroom.appointment_id = a.id +` + +function findAppointment(db, appointmentId) { + return db + .prepare(`${appointmentSelect} WHERE a.id = ? LIMIT 1`) + .get(appointmentId) +} + +function insertNotification( + db, + { userId, type, title, body, appointmentId, link = null, dedupeKey } +) { + db.prepare( + `INSERT INTO notifications ( + id, user_id, type, title, body, resource_type, resource_id, + link, dedupe_key + ) VALUES (?, ?, ?, ?, ?, 'appointment', ?, ?, ?)` + ).run(randomUUID(), userId, type, title, body, appointmentId, link, dedupeKey) +} + +function insertAudit(db, request, { action, appointmentId, details = {} }) { + db.prepare( + `INSERT INTO audit_logs ( + id, actor_id, action, entity_type, entity_id, details_json, + request_id, ip_address, user_agent + ) VALUES (?, ?, ?, 'appointment', ?, ?, ?, ?, ?)` + ).run( + randomUUID(), + request.auth.user.id, + action, + appointmentId, + JSON.stringify(details), + String(request.id ?? '').slice(0, 128) || null, + String(request.ip ?? '').slice(0, 64) || null, + String(request.headers['user-agent'] ?? '').slice(0, 512) || null + ) +} + +function roomCode() { + return randomBytes(12).toString('base64url') +} + +function conflictError(error) { + return ( + error?.code === 'SQLITE_CONSTRAINT_UNIQUE' || + error?.code === 'SQLITE_CONSTRAINT_PRIMARYKEY' || + /UNIQUE constraint failed/i.test(error?.message ?? '') + ) +} + +function classroomJoinWindow(classroom, now = Date.now()) { + const scheduledStart = Date.parse(classroom.scheduled_start) + const scheduledEnd = Date.parse(classroom.scheduled_end) + const opensAt = scheduledStart - CLASSROOM_JOIN_EARLY_MS + const closesAt = scheduledEnd + CLASSROOM_JOIN_GRACE_MS + + if (!Number.isFinite(opensAt) || !Number.isFinite(closesAt)) { + return { allowed: false, reason: 'INVALID_SCHEDULE' } + } + + const window = { + opensAt: new Date(opensAt).toISOString(), + closesAt: new Date(closesAt).toISOString() + } + if (now < opensAt) return { allowed: false, reason: 'TOO_EARLY', ...window } + if (now > closesAt) return { allowed: false, reason: 'TOO_LATE', ...window } + return { allowed: true, ...window } +} + +function isDevelopmentDemoClassroom(app, classroom) { + return ( + app.config?.nodeEnv === 'development' && + classroom.room_code?.startsWith('demo-') + ) +} + +function lifecycleFailure(code) { + const error = new Error(code) + error.lifecycleCode = code + return error +} + +export async function appointmentRoutes(app) { + const db = app.db + if (!db) { + throw new Error('appointmentRoutes requires app.db') + } + + app.post( + '/api/v1/appointments', + { preHandler: app.requireRole('student') }, + async (request, reply) => { + const result = createAppointmentSchema.safeParse(unwrapBody(request.body)) + if (!result.success) { + return validationError(reply, result) + } + + const startTime = new Date(result.data.scheduledStart) + const latestAllowed = Date.now() + 366 * 24 * 60 * 60 * 1000 + if (startTime.getTime() <= Date.now()) { + return responseError(reply, 400, '预约时间必须晚于当前时间') + } + if (startTime.getTime() > latestAllowed) { + return responseError(reply, 400, '预约时间不能超过未来一年') + } + + const teacher = db + .prepare( + `SELECT u.id, tp.verified_at + FROM users AS u + LEFT JOIN teacher_profiles AS tp ON tp.user_id = u.id + WHERE u.id = ? AND u.role = 'teacher' AND u.status = 'active' + LIMIT 1` + ) + .get(result.data.teacherId) + if (!teacher) { + return responseError(reply, 404, '教师不存在或当前不可预约') + } + if (!teacher.verified_at) { + return responseError(reply, 409, '该教师尚未通过平台认证,暂不可预约', { + verificationStatus: 'pending' + }) + } + + const courseId = result.data.courseId ?? null + if (courseId) { + const course = db + .prepare( + `SELECT id FROM courses + WHERE id = ? AND teacher_id = ? AND status = 'published' + LIMIT 1` + ) + .get(courseId, teacher.id) + if (!course) { + return responseError(reply, 400, '课程不存在、未发布或不属于该教师') + } + } + + const appointmentId = randomUUID() + const scheduledStart = startTime.toISOString() + const scheduledEnd = new Date( + startTime.getTime() + result.data.durationMinutes * 60_000 + ).toISOString() + const timestamp = new Date().toISOString() + + const createAppointment = db.transaction(() => { + db.prepare( + `INSERT INTO appointments ( + id, student_id, teacher_id, course_id, scheduled_start, + scheduled_end, topic, message, status, response_note, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', '', ?, ?)` + ).run( + appointmentId, + request.auth.user.id, + teacher.id, + courseId, + scheduledStart, + scheduledEnd, + result.data.topic, + result.data.message, + timestamp, + timestamp + ) + + insertNotification(db, { + userId: teacher.id, + type: 'appointment.requested', + title: '收到新的课堂预约', + body: `${request.auth.user.displayName} 预约了「${result.data.topic}」。`, + appointmentId, + link: '/teacher/teachingDocking', + dedupeKey: `appointment:${appointmentId}:requested:${teacher.id}` + }) + insertAudit(db, request, { + action: 'appointment.requested', + appointmentId, + details: { + teacherId: teacher.id, + courseId, + scheduledStart, + scheduledEnd + } + }) + }) + + try { + createAppointment() + } catch (error) { + if (conflictError(error)) { + return responseError(reply, 409, '相同时间的预约已经存在') + } + throw error + } + + return responseData( + reply, + appointmentData(findAppointment(db, appointmentId)), + '预约申请已提交', + 201 + ) + } + ) + + app.get( + '/api/v1/appointments', + { preHandler: app.authenticate }, + async (request, reply) => { + const result = listAppointmentsSchema.safeParse(request.query ?? {}) + if (!result.success) { + return validationError(reply, result) + } + + const filters = result.data + const role = request.auth.user.role + const userId = request.auth.user.id + const conditions = [] + const parameters = [] + + if (role === 'student') { + if (filters.studentId && filters.studentId !== userId) { + return responseError(reply, 403, '不能查看其他学生的预约') + } + conditions.push('a.student_id = ?') + parameters.push(userId) + } else if (role === 'teacher') { + if (filters.teacherId && filters.teacherId !== userId) { + return responseError(reply, 403, '不能查看其他教师的预约') + } + conditions.push('a.teacher_id = ?') + parameters.push(userId) + } else if (role !== 'administrator') { + return responseError(reply, 403, '没有权限查看预约') + } + + if (filters.status) { + conditions.push('a.status = ?') + parameters.push(filters.status) + } + if (filters.teacherId && role !== 'teacher') { + conditions.push('a.teacher_id = ?') + parameters.push(filters.teacherId) + } + if (filters.studentId && role !== 'student') { + conditions.push('a.student_id = ?') + parameters.push(filters.studentId) + } + if (filters.courseId) { + conditions.push('a.course_id = ?') + parameters.push(filters.courseId) + } + + const from = filters.from ? new Date(filters.from).toISOString() : null + const to = filters.to ? new Date(filters.to).toISOString() : null + if (from && to && from >= to) { + return responseError(reply, 400, '筛选结束时间必须晚于开始时间') + } + if (from) { + conditions.push('a.scheduled_end > ?') + parameters.push(from) + } + if (to) { + conditions.push('a.scheduled_start < ?') + parameters.push(to) + } + + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '' + const total = db + .prepare(`SELECT COUNT(*) AS count FROM appointments AS a ${where}`) + .get(...parameters).count + const offset = (filters.page - 1) * filters.pageSize + const appointments = db + .prepare( + `${appointmentSelect} + ${where} + ORDER BY a.scheduled_start ASC, a.created_at DESC, a.id ASC + LIMIT ? OFFSET ?` + ) + .all(...parameters, filters.pageSize, offset) + + return responseData(reply, { + items: appointments.map(appointmentData), + pagination: { + page: filters.page, + pageSize: filters.pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / filters.pageSize) + } + }) + } + ) + + app.patch( + '/api/v1/appointments/:id/respond', + { preHandler: app.requireRole('teacher') }, + async (request, reply) => { + const result = respondSchema.safeParse(unwrapBody(request.body)) + if (!result.success) { + return validationError(reply, result) + } + + const respond = db.transaction(() => { + const appointment = db + .prepare('SELECT * FROM appointments WHERE id = ? LIMIT 1') + .get(request.params.id) + + if (!appointment) { + return { error: 'NOT_FOUND' } + } + if (appointment.teacher_id !== request.auth.user.id) { + return { error: 'FORBIDDEN' } + } + if (appointment.status !== 'pending') { + return { error: 'INVALID_STATE', status: appointment.status } + } + + if (result.data.action === 'accept') { + const eligibleTeacher = db + .prepare( + `SELECT 1 + FROM users AS u + INNER JOIN teacher_profiles AS tp ON tp.user_id = u.id + WHERE u.id = ? + AND u.status = 'active' + AND tp.verified_at IS NOT NULL + LIMIT 1` + ) + .get(appointment.teacher_id) + if (!eligibleTeacher) { + return { error: 'TEACHER_NOT_VERIFIED' } + } + + const conflict = db + .prepare( + `SELECT id, teacher_id, student_id FROM appointments + WHERE (teacher_id = ? OR student_id = ?) + AND status = 'accepted' + AND id <> ? + AND scheduled_start < ? + AND scheduled_end > ? + ORDER BY scheduled_start ASC + LIMIT 1` + ) + .get( + appointment.teacher_id, + appointment.student_id, + appointment.id, + appointment.scheduled_end, + appointment.scheduled_start + ) + if (conflict) { + return { + error: 'SCHEDULE_CONFLICT', + conflictingAppointmentId: conflict.id, + conflictingParticipant: + conflict.teacher_id === appointment.teacher_id + ? 'teacher' + : 'student' + } + } + } + + const timestamp = new Date().toISOString() + const status = result.data.action === 'accept' ? 'accepted' : 'rejected' + const updated = db + .prepare( + `UPDATE appointments + SET status = ?, response_note = ?, updated_at = ? + WHERE id = ? AND status = 'pending'` + ) + .run(status, result.data.note, timestamp, appointment.id) + if (updated.changes !== 1) { + return { error: 'INVALID_STATE' } + } + + let classroomId = null + if (status === 'accepted') { + classroomId = randomUUID() + db.prepare( + `INSERT INTO classrooms ( + id, appointment_id, room_code, status, created_at, updated_at + ) VALUES (?, ?, ?, 'scheduled', ?, ?)` + ).run(classroomId, appointment.id, roomCode(), timestamp, timestamp) + + insertNotification(db, { + userId: appointment.student_id, + type: 'appointment.accepted', + title: '预约已被教师接受', + body: result.data.note || '教师已接受你的课堂预约。', + appointmentId: appointment.id, + link: `/student/liveClass?classroomId=${classroomId}`, + dedupeKey: `appointment:${appointment.id}:accepted:${appointment.student_id}` + }) + insertNotification(db, { + userId: appointment.teacher_id, + type: 'appointment.accepted', + title: '课堂预约已确认', + body: '课堂已进入排期,并已创建专属教室。', + appointmentId: appointment.id, + link: `/teacher/liveClass?classroomId=${classroomId}`, + dedupeKey: `appointment:${appointment.id}:accepted:${appointment.teacher_id}` + }) + } else { + insertNotification(db, { + userId: appointment.student_id, + type: 'appointment.rejected', + title: '预约未被接受', + body: result.data.note || '教师当前无法接受该预约。', + appointmentId: appointment.id, + dedupeKey: `appointment:${appointment.id}:rejected:${appointment.student_id}` + }) + } + + insertAudit(db, request, { + action: `appointment.${status}`, + appointmentId: appointment.id, + details: { + previousStatus: 'pending', + status, + note: result.data.note, + classroomId + } + }) + + return { appointmentId: appointment.id } + }) + + const outcome = respond() + if (outcome.error === 'NOT_FOUND') { + return responseError(reply, 404, '预约不存在') + } + if (outcome.error === 'FORBIDDEN') { + return responseError(reply, 403, '只能处理分配给自己的预约') + } + if (outcome.error === 'INVALID_STATE') { + return responseError(reply, 409, '只有待处理预约可以接受或拒绝', { + status: outcome.status ?? null + }) + } + if (outcome.error === 'TEACHER_NOT_VERIFIED') { + return responseError( + reply, + 409, + '教师身份认证当前无效,不能接受新的课堂预约' + ) + } + if (outcome.error === 'SCHEDULE_CONFLICT') { + return responseError(reply, 409, '该时间段与已接受的课堂冲突', { + conflictingAppointmentId: outcome.conflictingAppointmentId, + conflictingParticipant: outcome.conflictingParticipant + }) + } + + const appointment = findAppointment(db, outcome.appointmentId) + return responseData( + reply, + appointmentData(appointment), + result.data.action === 'accept' ? '预约已接受' : '预约已拒绝' + ) + } + ) + + app.patch( + '/api/v1/appointments/:id/cancel', + { preHandler: app.authenticate }, + async (request, reply) => { + const result = cancelSchema.safeParse(unwrapBody(request.body)) + if (!result.success) { + return validationError(reply, result) + } + + const cancel = db.transaction(() => { + const appointment = db + .prepare('SELECT * FROM appointments WHERE id = ? LIMIT 1') + .get(request.params.id) + if (!appointment) { + return { error: 'NOT_FOUND' } + } + + const role = request.auth.user.role + const ownsAppointment = + (role === 'student' && + appointment.student_id === request.auth.user.id) || + (role === 'teacher' && + appointment.teacher_id === request.auth.user.id) + if (!ownsAppointment) { + return { error: 'FORBIDDEN' } + } + if (!['pending', 'accepted'].includes(appointment.status)) { + return { error: 'INVALID_STATE', status: appointment.status } + } + + const timestamp = new Date().toISOString() + const updated = db + .prepare( + `UPDATE appointments + SET status = 'cancelled', updated_at = ? + WHERE id = ? AND status IN ('pending', 'accepted')` + ) + .run(timestamp, appointment.id) + if (updated.changes !== 1) { + return { error: 'INVALID_STATE' } + } + + db.prepare( + `UPDATE classrooms + SET status = 'closed', + opened_at = COALESCE(opened_at, ?), + closed_at = ?, + updated_at = ? + WHERE appointment_id = ? AND status <> 'closed'` + ).run(timestamp, timestamp, timestamp, appointment.id) + + const otherParticipantId = + role === 'student' ? appointment.teacher_id : appointment.student_id + insertNotification(db, { + userId: otherParticipantId, + type: 'appointment.cancelled', + title: '课堂预约已取消', + body: result.data.reason || '预约已由另一位参与者取消。', + appointmentId: appointment.id, + dedupeKey: `appointment:${appointment.id}:cancelled:${otherParticipantId}` + }) + insertAudit(db, request, { + action: 'appointment.cancelled', + appointmentId: appointment.id, + details: { + previousStatus: appointment.status, + cancelledBy: role, + reason: result.data.reason + } + }) + + return { appointmentId: appointment.id } + }) + + const outcome = cancel() + if (outcome.error === 'NOT_FOUND') { + return responseError(reply, 404, '预约不存在') + } + if (outcome.error === 'FORBIDDEN') { + return responseError(reply, 403, '只能取消自己参与的预约') + } + if (outcome.error === 'INVALID_STATE') { + return responseError(reply, 409, '当前预约状态不能取消', { + status: outcome.status ?? null + }) + } + + return responseData( + reply, + appointmentData(findAppointment(db, outcome.appointmentId)), + '预约已取消' + ) + } + ) + + app.post( + '/api/v1/classrooms/:id/complete', + { preHandler: app.authenticate }, + async (request, reply) => { + const complete = db.transaction(() => { + const classroom = db + .prepare( + `SELECT + classroom.id AS classroom_id, + classroom.status AS classroom_status, + a.* + FROM classrooms AS classroom + INNER JOIN appointments AS a ON a.id = classroom.appointment_id + WHERE classroom.id = ? + LIMIT 1` + ) + .get(request.params.id) + + if (!classroom) return { error: 'NOT_FOUND' } + + const role = request.auth.user.role + const participates = + (role === 'student' && + classroom.student_id === request.auth.user.id) || + (role === 'teacher' && classroom.teacher_id === request.auth.user.id) + if (!participates) return { error: 'FORBIDDEN' } + if ( + classroom.status !== 'accepted' || + classroom.classroom_status !== 'open' + ) { + return { + error: 'INVALID_STATE', + appointmentStatus: classroom.status, + classroomStatus: classroom.classroom_status + } + } + + const timestamp = new Date().toISOString() + const appointmentUpdated = db + .prepare( + `UPDATE appointments + SET status = 'completed', updated_at = ? + WHERE id = ? AND status = 'accepted'` + ) + .run(timestamp, classroom.id) + if (appointmentUpdated.changes !== 1) { + throw lifecycleFailure('APPOINTMENT_UPDATE_FAILED') + } + + const classroomUpdated = db + .prepare( + `UPDATE classrooms + SET status = 'closed', + closed_at = ?, + updated_at = ? + WHERE id = ? AND status = 'open'` + ) + .run(timestamp, timestamp, classroom.classroom_id) + if (classroomUpdated.changes !== 1) { + throw lifecycleFailure('CLASSROOM_UPDATE_FAILED') + } + + const otherParticipantId = + role === 'student' ? classroom.teacher_id : classroom.student_id + insertNotification(db, { + userId: otherParticipantId, + type: 'appointment.completed', + title: '课堂已完成', + body: `${request.auth.user.displayName} 已将「${classroom.topic}」标记为完成。`, + appointmentId: classroom.id, + link: + role === 'student' ? '/teacher/teachingDocking' : '/student/home', + dedupeKey: `appointment:${classroom.id}:completed:${otherParticipantId}` + }) + insertAudit(db, request, { + action: 'appointment.completed', + appointmentId: classroom.id, + details: { + previousStatus: 'accepted', + previousClassroomStatus: classroom.classroom_status, + classroomId: classroom.classroom_id, + completedBy: role + } + }) + + return { appointmentId: classroom.id } + }) + + let outcome + try { + outcome = complete() + } catch (error) { + if (error.lifecycleCode) { + request.log.error( + { err: error, lifecycleCode: error.lifecycleCode }, + 'Failed to complete classroom atomically' + ) + return responseError(reply, 409, '课堂状态已发生变化,请刷新后重试') + } + throw error + } + + if (outcome.error === 'NOT_FOUND') { + return responseError(reply, 404, '课堂不存在') + } + if (outcome.error === 'FORBIDDEN') { + return responseError(reply, 403, '只有课堂参与者可以完成课堂') + } + if (outcome.error === 'INVALID_STATE') { + return responseError(reply, 409, '只有已进入且仍在进行的课堂可以完成', { + appointmentStatus: outcome.appointmentStatus, + classroomStatus: outcome.classroomStatus + }) + } + + return responseData( + reply, + appointmentData(findAppointment(db, outcome.appointmentId)), + '课堂已完成' + ) + } + ) + + app.get( + '/api/v1/classrooms/:id/join-info', + { preHandler: app.authenticate }, + async (request, reply) => { + const classroom = db + .prepare( + `SELECT + classroom.id, + classroom.room_code, + classroom.status AS classroom_status, + a.status AS appointment_status, + a.scheduled_start, + a.scheduled_end, + a.student_id, + a.teacher_id, + student.display_name AS student_display_name, + student.avatar_url AS student_avatar_url, + teacher.display_name AS teacher_display_name, + teacher.avatar_url AS teacher_avatar_url + FROM classrooms AS classroom + INNER JOIN appointments AS a ON a.id = classroom.appointment_id + INNER JOIN users AS student ON student.id = a.student_id + INNER JOIN users AS teacher ON teacher.id = a.teacher_id + WHERE classroom.id = ? + LIMIT 1` + ) + .get(request.params.id) + + if (!classroom) { + return responseError(reply, 404, '课堂不存在') + } + if ( + request.auth.user.id !== classroom.student_id && + request.auth.user.id !== classroom.teacher_id + ) { + return responseError(reply, 403, '只有课堂参与者可以获取加入信息') + } + if ( + classroom.appointment_status !== 'accepted' || + classroom.classroom_status === 'closed' + ) { + return responseError(reply, 409, '课堂当前不可加入') + } + + const accessWindow = classroomJoinWindow(classroom) + if ( + !accessWindow.allowed && + !isDevelopmentDemoClassroom(app, classroom) + ) { + return responseError(reply, 409, '当前不在课堂可加入时间内', { + reason: accessWindow.reason, + opensAt: accessWindow.opensAt ?? null, + closesAt: accessWindow.closesAt ?? null + }) + } + + const iceServers = [] + if (app.config?.turnUrl) { + iceServers.push({ + urls: app.config.turnUrl, + ...(app.config.turnUsername + ? { username: app.config.turnUsername } + : {}), + ...(app.config.turnCredential + ? { credential: app.config.turnCredential } + : {}) + }) + } + + return responseData(reply, { + classroomId: classroom.id, + roomCode: classroom.room_code, + accessWindow: { + opensAt: accessWindow.opensAt ?? null, + closesAt: accessWindow.closesAt ?? null, + demoBypass: + !accessWindow.allowed && isDevelopmentDemoClassroom(app, classroom) + }, + iceServers, + participants: [ + { + id: classroom.student_id, + displayName: classroom.student_display_name, + avatarUrl: classroom.student_avatar_url ?? null, + role: 'student' + }, + { + id: classroom.teacher_id, + displayName: classroom.teacher_display_name, + avatarUrl: classroom.teacher_avatar_url ?? null, + role: 'teacher' + } + ] + }) + } + ) +} + +export default appointmentRoutes diff --git a/server/routes/assignments.js b/server/routes/assignments.js new file mode 100644 index 0000000..702ef03 --- /dev/null +++ b/server/routes/assignments.js @@ -0,0 +1,1394 @@ +import { randomUUID } from 'node:crypto' + +import { z } from 'zod' + +const identifierSchema = z.string().uuid('ID 格式不正确') +const assignmentStatusSchema = z.enum(['draft', 'published', 'closed']) +const submissionStatusSchema = z.enum(['draft', 'submitted', 'graded']) + +const assignmentIdParamsSchema = z.object({ id: identifierSchema }).strict() +const courseIdParamsSchema = z.object({ courseId: identifierSchema }).strict() +const submissionIdParamsSchema = z.object({ id: identifierSchema }).strict() + +const nullableDateTimeSchema = z.preprocess( + (value) => (value === '' ? null : value), + z + .union([ + z.null(), + z + .string() + .trim() + .refine( + (value) => Number.isFinite(Date.parse(value)), + '日期时间格式不正确' + ) + ]) + .transform((value) => + value === null ? null : new Date(value).toISOString() + ) +) + +const questionSchema = z + .preprocess( + (value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return value + } + + const { questionType, ...question } = value + return { + ...question, + type: value.type ?? questionType + } + }, + z + .object({ + type: z.enum(['single_choice', 'multiple_choice', 'text']), + prompt: z.string().trim().min(1).max(4000), + options: z + .array(z.string().trim().min(1).max(500)) + .min(2) + .max(20) + .nullable() + .optional(), + correctAnswer: z.unknown().nullable().optional(), + points: z.coerce.number().finite().positive().max(10000), + explanation: z.string().trim().max(4000).default('') + }) + .strict() + ) + .superRefine((question, context) => { + if (!question || typeof question !== 'object') return + + if (question.type === 'text') { + if ( + question.correctAnswer !== undefined && + question.correctAnswer !== null && + (typeof question.correctAnswer !== 'string' || + question.correctAnswer.length > 10000) + ) { + context.addIssue({ + code: 'custom', + path: ['correctAnswer'], + message: '文本题参考答案必须是字符串' + }) + } + return + } + + const options = question.options ?? [] + if (options.length < 2) { + context.addIssue({ + code: 'custom', + path: ['options'], + message: '选择题至少需要两个选项' + }) + return + } + if (new Set(options).size !== options.length) { + context.addIssue({ + code: 'custom', + path: ['options'], + message: '选择题选项不能重复' + }) + } + + if (question.type === 'single_choice') { + if ( + typeof question.correctAnswer !== 'string' || + !options.includes(question.correctAnswer) + ) { + context.addIssue({ + code: 'custom', + path: ['correctAnswer'], + message: '单选题正确答案必须是已有选项' + }) + } + return + } + + if ( + !Array.isArray(question.correctAnswer) || + question.correctAnswer.length === 0 || + question.correctAnswer.some( + (answer) => typeof answer !== 'string' || !options.includes(answer) + ) || + new Set(question.correctAnswer).size !== question.correctAnswer.length + ) { + context.addIssue({ + code: 'custom', + path: ['correctAnswer'], + message: '多选题正确答案必须是互不重复的已有选项' + }) + } + }) + +const createAssignmentSchema = z + .object({ + title: z.string().trim().min(1).max(160), + instructions: z.string().trim().max(10000).default(''), + dueAt: nullableDateTimeSchema.optional().default(null), + maxScore: z.coerce.number().finite().positive().max(10000), + questions: z.array(questionSchema).min(1).max(100) + }) + .strict() + .superRefine((value, context) => { + const total = value.questions.reduce( + (sum, question) => sum + question.points, + 0 + ) + if (!scoresEqual(total, value.maxScore)) { + context.addIssue({ + code: 'custom', + path: ['questions'], + message: '题目分值总和必须等于作业满分' + }) + } + }) + +const updateAssignmentSchema = z + .object({ + title: z.string().trim().min(1).max(160).optional(), + instructions: z.string().trim().max(10000).optional(), + dueAt: nullableDateTimeSchema.optional(), + maxScore: z.coerce.number().finite().positive().max(10000).optional(), + questions: z.array(questionSchema).min(1).max(100).optional() + }) + .strict() + .refine((value) => Object.values(value).some((item) => item !== undefined), { + message: '至少需要提供一个可修改字段' + }) + +const saveSubmissionSchema = z + .object({ + answers: z.record(identifierSchema, z.unknown()), + action: z.enum(['save', 'submit']).default('submit') + }) + .strict() + +const gradeSubmissionSchema = z + .object({ + score: z.coerce.number().finite().min(0), + feedback: z.string().trim().max(5000).default('') + }) + .strict() + +const submissionListQuerySchema = z + .object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), + status: submissionStatusSchema.optional() + }) + .strict() + +const teacherSubmissionListQuerySchema = z + .object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), + status: z.enum(['submitted', 'graded']).optional() + }) + .strict() + +function scoresEqual(left, right) { + return Math.abs(Number(left) - Number(right)) < 0.001 +} + +function deadlinePassed(dueAt, now = Date.now()) { + return dueAt !== null && Date.parse(dueAt) <= now +} + +function responseData(reply, data, message = '操作成功', statusCode = 200) { + return reply.code(statusCode).send({ code: 0, msg: message, data }) +} + +function responseError(reply, statusCode, message, data = null) { + return reply.code(statusCode).send({ + code: statusCode, + msg: message, + data + }) +} + +function validationError(reply, result) { + return responseError(reply, 400, '请求参数不正确', { + errors: result.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message + })) + }) +} + +function unwrapBody(body) { + if (body && typeof body === 'object' && body.data) { + return body.data + } + + return body ?? {} +} + +function parseJson(value, fallback) { + if (typeof value !== 'string') return fallback + + try { + return JSON.parse(value) + } catch { + return fallback + } +} + +function conflictError(error) { + return ( + error?.code === 'SQLITE_CONSTRAINT_UNIQUE' || + error?.code === 'SQLITE_CONSTRAINT_PRIMARYKEY' || + /UNIQUE constraint failed/i.test(error?.message ?? '') + ) +} + +function pagination(page, pageSize, total) { + return { + page, + pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / pageSize) + } +} + +const assignmentSelect = ` + SELECT + a.*, + c.status AS course_status, + c.title AS course_title, + c.teacher_id AS course_teacher_id + FROM assignments AS a + INNER JOIN courses AS c ON c.id = a.course_id +` + +function findAssignment(db, assignmentId) { + return db + .prepare(`${assignmentSelect} WHERE a.id = ? LIMIT 1`) + .get(assignmentId) +} + +function findQuestions(db, assignmentId) { + return db + .prepare( + `SELECT * FROM assignment_questions + WHERE assignment_id = ? + ORDER BY position ASC, id ASC` + ) + .all(assignmentId) +} + +function questionData(row, { includeCorrectAnswer = false } = {}) { + const question = { + id: row.id, + position: Number(row.position), + type: row.question_type, + prompt: row.prompt, + options: parseJson(row.options_json, null), + points: Number(row.points) + } + + if (includeCorrectAnswer) { + question.correctAnswer = parseJson(row.correct_answer_json, null) + question.explanation = row.explanation + } + + return question +} + +function submissionSummaryFromJoinedRow(row) { + if (!row.submission_id) return null + + return { + id: row.submission_id, + status: row.submission_status, + submittedAt: row.submission_submitted_at ?? null, + score: + row.submission_score === null || row.submission_score === undefined + ? null + : Number(row.submission_score), + feedback: row.submission_feedback ?? '', + gradedAt: row.submission_graded_at ?? null + } +} + +function assignmentSummary(row) { + const summary = { + id: row.id, + courseId: row.course_id, + teacherId: row.teacher_id, + title: row.title, + instructions: row.instructions, + dueAt: row.due_at ?? null, + maxScore: Number(row.max_score), + status: assignmentStatusSchema.parse(row.status), + questionCount: Number(row.question_count ?? 0), + createdAt: row.created_at, + updatedAt: row.updated_at + } + + if ('submission_id' in row) { + summary.submission = submissionSummaryFromJoinedRow(row) + } + + return summary +} + +function submissionData(row, { includeStudent = false } = {}) { + const submission = { + id: row.id, + assignmentId: row.assignment_id, + studentId: row.student_id, + answers: parseJson(row.answers_json, {}), + status: submissionStatusSchema.parse(row.status), + submittedAt: row.submitted_at ?? null, + score: row.score === null ? null : Number(row.score), + feedback: row.feedback, + gradedBy: row.graded_by ?? null, + gradedAt: row.graded_at ?? null, + createdAt: row.created_at, + updatedAt: row.updated_at + } + + if (includeStudent) { + submission.student = { + id: row.student_id, + displayName: row.student_display_name, + avatarUrl: row.student_avatar_url ?? null + } + } + + if (row.assignment_title) { + submission.assignment = { + id: row.assignment_id, + title: row.assignment_title, + courseId: row.course_id, + courseTitle: row.course_title, + dueAt: row.due_at ?? null, + maxScore: Number(row.max_score) + } + } + + return submission +} + +function assignmentDetail(db, row, { includeCorrectAnswer, studentId }) { + const questions = findQuestions(db, row.id) + const detail = { + ...assignmentSummary(row), + questionCount: questions.length, + course: { + id: row.course_id, + title: row.course_title, + status: row.course_status + }, + questions: questions.map((question) => + questionData(question, { includeCorrectAnswer }) + ) + } + + if (studentId) { + const submission = db + .prepare( + `SELECT * FROM submissions + WHERE assignment_id = ? AND student_id = ? + LIMIT 1` + ) + .get(row.id, studentId) + detail.submission = submission ? submissionData(submission) : null + } + + return detail +} + +function insertQuestions(db, assignmentId, questions) { + const insert = db.prepare( + `INSERT INTO assignment_questions ( + id, assignment_id, position, question_type, prompt, options_json, + correct_answer_json, points, explanation + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + + questions.forEach((question, index) => { + insert.run( + randomUUID(), + assignmentId, + index + 1, + question.type, + question.prompt, + question.type === 'text' ? null : JSON.stringify(question.options ?? []), + JSON.stringify(question.correctAnswer ?? null), + question.points, + question.explanation + ) + }) +} + +function insertAudit(db, request, { action, entityType, entityId, details }) { + db.prepare( + `INSERT INTO audit_logs ( + id, actor_id, action, entity_type, entity_id, details_json, + request_id, ip_address, user_agent + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + randomUUID(), + request.auth.user.id, + action, + entityType, + entityId, + JSON.stringify(details ?? {}), + String(request.id ?? '').slice(0, 128) || null, + String(request.ip ?? '').slice(0, 64) || null, + String(request.headers['user-agent'] ?? '').slice(0, 512) || null + ) +} + +function answerErrors(questions, answers, requireComplete) { + const issues = [] + const questionsById = new Map( + questions.map((question) => [question.id, question]) + ) + + for (const questionId of Object.keys(answers)) { + if (!questionsById.has(questionId)) { + issues.push({ field: `answers.${questionId}`, message: '题目不存在' }) + } + } + + for (const question of questions) { + const answer = answers[question.id] + const empty = + answer === undefined || + answer === null || + answer === '' || + (Array.isArray(answer) && answer.length === 0) + + if (empty) { + if (requireComplete) { + issues.push({ + field: `answers.${question.id}`, + message: '提交前必须完成所有题目' + }) + } + continue + } + + const options = parseJson(question.options_json, []) + if (question.question_type === 'single_choice') { + if (typeof answer !== 'string' || !options.includes(answer)) { + issues.push({ + field: `answers.${question.id}`, + message: '单选题答案必须是已有选项' + }) + } + } else if (question.question_type === 'multiple_choice') { + if ( + !Array.isArray(answer) || + answer.some( + (item) => typeof item !== 'string' || !options.includes(item) + ) || + new Set(answer).size !== answer.length + ) { + issues.push({ + field: `answers.${question.id}`, + message: '多选题答案必须是互不重复的已有选项' + }) + } + } else if (typeof answer !== 'string' || answer.length > 10000) { + issues.push({ + field: `answers.${question.id}`, + message: '文本题答案必须是不超过 10000 字符的字符串' + }) + } + } + + return issues +} + +export async function assignmentRoutes(app) { + const db = app.db + if (!db) { + throw new Error('assignmentRoutes requires app.db') + } + + app.get( + '/api/v1/courses/:courseId/assignments', + { preHandler: app.authenticate }, + async (request, reply) => { + const paramsResult = courseIdParamsSchema.safeParse(request.params) + if (!paramsResult.success) return validationError(reply, paramsResult) + + const course = db + .prepare('SELECT * FROM courses WHERE id = ? LIMIT 1') + .get(paramsResult.data.courseId) + if (!course) return responseError(reply, 404, '课程不存在') + + const { role, id: userId } = request.auth.user + if (role === 'student' && course.status !== 'published') { + return responseError(reply, 404, '课程不存在') + } + if (role === 'teacher' && course.teacher_id !== userId) { + return responseError(reply, 403, '只能查看自己课程的作业') + } + if (!['student', 'teacher', 'administrator'].includes(role)) { + return responseError(reply, 403, '没有权限查看作业') + } + + let rows + if (role === 'student') { + rows = db + .prepare( + `SELECT + a.*, + COUNT(q.id) AS question_count, + s.id AS submission_id, + s.status AS submission_status, + s.submitted_at AS submission_submitted_at, + s.score AS submission_score, + s.feedback AS submission_feedback, + s.graded_at AS submission_graded_at + FROM assignments AS a + LEFT JOIN assignment_questions AS q ON q.assignment_id = a.id + LEFT JOIN submissions AS s + ON s.assignment_id = a.id AND s.student_id = ? + WHERE a.course_id = ? AND a.status = 'published' + GROUP BY a.id + ORDER BY a.due_at IS NULL, a.due_at ASC, a.created_at DESC` + ) + .all(userId, course.id) + } else { + rows = db + .prepare( + `SELECT a.*, COUNT(q.id) AS question_count + FROM assignments AS a + LEFT JOIN assignment_questions AS q ON q.assignment_id = a.id + WHERE a.course_id = ? + GROUP BY a.id + ORDER BY a.created_at DESC, a.id ASC` + ) + .all(course.id) + } + + return responseData(reply, { items: rows.map(assignmentSummary) }) + } + ) + + app.post( + '/api/v1/courses/:courseId/assignments', + { preHandler: app.requireRole('teacher') }, + async (request, reply) => { + const paramsResult = courseIdParamsSchema.safeParse(request.params) + if (!paramsResult.success) return validationError(reply, paramsResult) + + const bodyResult = createAssignmentSchema.safeParse( + unwrapBody(request.body) + ) + if (!bodyResult.success) return validationError(reply, bodyResult) + + const input = bodyResult.data + const assignmentId = randomUUID() + const timestamp = new Date().toISOString() + const create = db.transaction(() => { + const course = db + .prepare('SELECT * FROM courses WHERE id = ? LIMIT 1') + .get(paramsResult.data.courseId) + if (!course) return { error: 'NOT_FOUND' } + if (course.teacher_id !== request.auth.user.id) { + return { error: 'FORBIDDEN' } + } + if (course.status === 'archived') { + return { error: 'COURSE_ARCHIVED' } + } + + db.prepare( + `INSERT INTO assignments ( + id, course_id, teacher_id, title, instructions, due_at, + max_score, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?)` + ).run( + assignmentId, + course.id, + request.auth.user.id, + input.title, + input.instructions, + input.dueAt, + input.maxScore, + timestamp, + timestamp + ) + insertQuestions(db, assignmentId, input.questions) + insertAudit(db, request, { + action: 'assignment.created', + entityType: 'assignment', + entityId: assignmentId, + details: { courseId: course.id, status: 'draft' } + }) + return { assignmentId } + }) + + let outcome + try { + outcome = create() + } catch (error) { + if (conflictError(error)) { + return responseError(reply, 409, '该课程中已存在同名作业') + } + throw error + } + + if (outcome.error === 'NOT_FOUND') { + return responseError(reply, 404, '课程不存在') + } + if (outcome.error === 'FORBIDDEN') { + return responseError(reply, 403, '只能为自己的课程创建作业') + } + if (outcome.error === 'COURSE_ARCHIVED') { + return responseError(reply, 409, '归档课程不能创建作业') + } + + return responseData( + reply, + assignmentDetail(db, findAssignment(db, assignmentId), { + includeCorrectAnswer: true + }), + '作业草稿已创建', + 201 + ) + } + ) + + app.patch( + '/api/v1/assignments/:id', + { preHandler: app.requireRole('teacher') }, + async (request, reply) => { + const paramsResult = assignmentIdParamsSchema.safeParse(request.params) + if (!paramsResult.success) return validationError(reply, paramsResult) + + const bodyResult = updateAssignmentSchema.safeParse( + unwrapBody(request.body) + ) + if (!bodyResult.success) return validationError(reply, bodyResult) + + const input = bodyResult.data + const update = db.transaction(() => { + const assignment = findAssignment(db, paramsResult.data.id) + if (!assignment) return { error: 'NOT_FOUND' } + if (assignment.teacher_id !== request.auth.user.id) { + return { error: 'FORBIDDEN' } + } + if (assignment.status !== 'draft') { + return { error: 'INVALID_STATE', status: assignment.status } + } + + const nextMaxScore = input.maxScore ?? Number(assignment.max_score) + const questionTotal = input.questions + ? input.questions.reduce((sum, question) => sum + question.points, 0) + : Number( + db + .prepare( + `SELECT COALESCE(SUM(points), 0) AS total + FROM assignment_questions WHERE assignment_id = ?` + ) + .get(assignment.id).total + ) + if (!scoresEqual(questionTotal, nextMaxScore)) { + return { + error: 'INVALID_POINTS', + maxScore: nextMaxScore, + questionTotal + } + } + + const columns = { + title: 'title', + instructions: 'instructions', + dueAt: 'due_at', + maxScore: 'max_score' + } + const assignments = [] + const values = [] + for (const [field, column] of Object.entries(columns)) { + if (input[field] !== undefined) { + assignments.push(`${column} = ?`) + values.push(input[field]) + } + } + const timestamp = new Date().toISOString() + assignments.push('updated_at = ?') + values.push(timestamp, assignment.id) + db.prepare( + `UPDATE assignments SET ${assignments.join(', ')} WHERE id = ?` + ).run(...values) + + if (input.questions) { + db.prepare( + 'DELETE FROM assignment_questions WHERE assignment_id = ?' + ).run(assignment.id) + insertQuestions(db, assignment.id, input.questions) + } + + insertAudit(db, request, { + action: 'assignment.updated', + entityType: 'assignment', + entityId: assignment.id, + details: { fields: Object.keys(input) } + }) + return { assignmentId: assignment.id } + }) + + let outcome + try { + outcome = update() + } catch (error) { + if (conflictError(error)) { + return responseError(reply, 409, '该课程中已存在同名作业') + } + throw error + } + + if (outcome.error === 'NOT_FOUND') { + return responseError(reply, 404, '作业不存在') + } + if (outcome.error === 'FORBIDDEN') { + return responseError(reply, 403, '只能修改自己的作业') + } + if (outcome.error === 'INVALID_STATE') { + return responseError(reply, 409, '只有草稿状态的作业可以修改', { + currentStatus: outcome.status, + requiredStatus: 'draft' + }) + } + if (outcome.error === 'INVALID_POINTS') { + return responseError(reply, 400, '题目分值总和必须等于作业满分', { + maxScore: outcome.maxScore, + questionTotal: outcome.questionTotal + }) + } + + return responseData( + reply, + assignmentDetail(db, findAssignment(db, outcome.assignmentId), { + includeCorrectAnswer: true + }), + '作业草稿已更新' + ) + } + ) + + app.post( + '/api/v1/assignments/:id/publish', + { preHandler: app.requireRole('teacher') }, + async (request, reply) => { + const paramsResult = assignmentIdParamsSchema.safeParse(request.params) + if (!paramsResult.success) return validationError(reply, paramsResult) + + const publish = db.transaction(() => { + const assignment = findAssignment(db, paramsResult.data.id) + if (!assignment) return { error: 'NOT_FOUND' } + if (assignment.teacher_id !== request.auth.user.id) { + return { error: 'FORBIDDEN' } + } + if (assignment.status !== 'draft') { + return { error: 'INVALID_STATE', status: assignment.status } + } + if (deadlinePassed(assignment.due_at)) { + return { error: 'DEADLINE_PASSED', dueAt: assignment.due_at } + } + + const questions = db + .prepare( + `SELECT COUNT(*) AS count, COALESCE(SUM(points), 0) AS total + FROM assignment_questions WHERE assignment_id = ?` + ) + .get(assignment.id) + if ( + questions.count === 0 || + !scoresEqual(questions.total, assignment.max_score) + ) { + return { + error: 'INCOMPLETE', + questionCount: questions.count, + questionTotal: Number(questions.total), + maxScore: Number(assignment.max_score) + } + } + + const timestamp = new Date().toISOString() + const updated = db + .prepare( + `UPDATE assignments + SET status = 'published', updated_at = ? + WHERE id = ? AND teacher_id = ? AND status = 'draft'` + ) + .run(timestamp, assignment.id, request.auth.user.id) + if (updated.changes !== 1) return { error: 'STATE_CHANGED' } + + insertAudit(db, request, { + action: 'assignment.published', + entityType: 'assignment', + entityId: assignment.id, + details: { previousStatus: 'draft', status: 'published' } + }) + return { assignmentId: assignment.id } + }) + + const outcome = publish() + if (outcome.error === 'NOT_FOUND') { + return responseError(reply, 404, '作业不存在') + } + if (outcome.error === 'FORBIDDEN') { + return responseError(reply, 403, '只能发布自己的作业') + } + if (outcome.error === 'INVALID_STATE') { + return responseError(reply, 409, '只有草稿状态的作业可以发布', { + currentStatus: outcome.status, + requiredStatus: 'draft' + }) + } + if (outcome.error === 'DEADLINE_PASSED') { + return responseError(reply, 409, '作业截止时间已过,请先更新截止时间', { + dueAt: outcome.dueAt + }) + } + if (outcome.error === 'INCOMPLETE') { + return responseError(reply, 400, '作业题目与分值配置不完整', { + questionCount: outcome.questionCount, + questionTotal: outcome.questionTotal, + maxScore: outcome.maxScore + }) + } + if (outcome.error === 'STATE_CHANGED') { + return responseError(reply, 409, '作业状态已变化,请刷新后重试') + } + + return responseData( + reply, + assignmentDetail(db, findAssignment(db, outcome.assignmentId), { + includeCorrectAnswer: true + }), + '作业已发布' + ) + } + ) + + app.post( + '/api/v1/assignments/:id/close', + { preHandler: app.requireRole('teacher') }, + async (request, reply) => { + const paramsResult = assignmentIdParamsSchema.safeParse(request.params) + if (!paramsResult.success) return validationError(reply, paramsResult) + + const close = db.transaction(() => { + const assignment = findAssignment(db, paramsResult.data.id) + if (!assignment) return { error: 'NOT_FOUND' } + if (assignment.teacher_id !== request.auth.user.id) { + return { error: 'FORBIDDEN' } + } + if (assignment.status !== 'published') { + return { error: 'INVALID_STATE', status: assignment.status } + } + + const timestamp = new Date().toISOString() + const updated = db + .prepare( + `UPDATE assignments + SET status = 'closed', updated_at = ? + WHERE id = ? AND teacher_id = ? AND status = 'published'` + ) + .run(timestamp, assignment.id, request.auth.user.id) + if (updated.changes !== 1) return { error: 'STATE_CHANGED' } + + insertAudit(db, request, { + action: 'assignment.closed', + entityType: 'assignment', + entityId: assignment.id, + details: { previousStatus: 'published', status: 'closed' } + }) + return { assignmentId: assignment.id } + }) + + const outcome = close() + if (outcome.error === 'NOT_FOUND') { + return responseError(reply, 404, '作业不存在') + } + if (outcome.error === 'FORBIDDEN') { + return responseError(reply, 403, '只能关闭自己的作业') + } + if (outcome.error === 'INVALID_STATE') { + return responseError(reply, 409, '只有已发布状态的作业可以关闭', { + currentStatus: outcome.status, + requiredStatus: 'published' + }) + } + if (outcome.error === 'STATE_CHANGED') { + return responseError(reply, 409, '作业状态已变化,请刷新后重试') + } + + return responseData( + reply, + assignmentDetail(db, findAssignment(db, outcome.assignmentId), { + includeCorrectAnswer: true + }), + '作业已关闭' + ) + } + ) + + app.get( + '/api/v1/assignments/:id', + { preHandler: app.authenticate }, + async (request, reply) => { + const paramsResult = assignmentIdParamsSchema.safeParse(request.params) + if (!paramsResult.success) return validationError(reply, paramsResult) + + const assignment = findAssignment(db, paramsResult.data.id) + if (!assignment) return responseError(reply, 404, '作业不存在') + + const { role, id: userId } = request.auth.user + if (role === 'student') { + if ( + !['published', 'closed'].includes(assignment.status) || + !['published', 'archived'].includes(assignment.course_status) + ) { + return responseError(reply, 404, '作业不存在') + } + return responseData( + reply, + assignmentDetail(db, assignment, { + includeCorrectAnswer: false, + studentId: userId + }) + ) + } + + if (role === 'teacher' && assignment.teacher_id !== userId) { + return responseError(reply, 403, '只能查看自己的作业') + } + if (!['teacher', 'administrator'].includes(role)) { + return responseError(reply, 403, '没有权限查看作业') + } + + return responseData( + reply, + assignmentDetail(db, assignment, { includeCorrectAnswer: true }) + ) + } + ) + + app.post( + '/api/v1/assignments/:id/submissions', + { preHandler: app.requireRole('student') }, + async (request, reply) => { + const paramsResult = assignmentIdParamsSchema.safeParse(request.params) + if (!paramsResult.success) return validationError(reply, paramsResult) + + const bodyResult = saveSubmissionSchema.safeParse( + unwrapBody(request.body) + ) + if (!bodyResult.success) return validationError(reply, bodyResult) + + const input = bodyResult.data + const save = db.transaction(() => { + const assignment = findAssignment(db, paramsResult.data.id) + if (!assignment) return { error: 'NOT_FOUND' } + if (assignment.status === 'closed') { + return { error: 'ASSIGNMENT_CLOSED' } + } + if (assignment.status !== 'published') { + return { error: 'NOT_AVAILABLE', status: assignment.status } + } + if (assignment.course_status === 'archived') { + return { error: 'COURSE_ARCHIVED' } + } + if (assignment.course_status !== 'published') { + return { + error: 'NOT_AVAILABLE', + status: assignment.status, + courseStatus: assignment.course_status + } + } + + const existing = db + .prepare( + `SELECT * FROM submissions + WHERE assignment_id = ? AND student_id = ? LIMIT 1` + ) + .get(assignment.id, request.auth.user.id) + if (existing && existing.status !== 'draft') { + return { error: 'INVALID_STATE', status: existing.status } + } + + if (deadlinePassed(assignment.due_at)) { + if (input.action === 'submit') { + return { error: 'DEADLINE_PASSED', dueAt: assignment.due_at } + } + if (!existing) { + return { + error: 'DEADLINE_PASSED_NEW_DRAFT', + dueAt: assignment.due_at + } + } + } + + const questions = findQuestions(db, assignment.id) + const issues = answerErrors( + questions, + input.answers, + input.action === 'submit' + ) + if (issues.length > 0) return { error: 'INVALID_ANSWERS', issues } + + const submissionId = existing?.id ?? randomUUID() + const timestamp = new Date().toISOString() + const nextStatus = input.action === 'submit' ? 'submitted' : 'draft' + const submittedAt = nextStatus === 'submitted' ? timestamp : null + + if (existing) { + const updated = db + .prepare( + `UPDATE submissions + SET answers_json = ?, status = ?, submitted_at = ?, updated_at = ? + WHERE id = ? AND student_id = ? AND status = 'draft'` + ) + .run( + JSON.stringify(input.answers), + nextStatus, + submittedAt, + timestamp, + existing.id, + request.auth.user.id + ) + if (updated.changes !== 1) return { error: 'STATE_CHANGED' } + } else { + db.prepare( + `INSERT INTO submissions ( + id, assignment_id, student_id, answers_json, status, + submitted_at, feedback, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, '', ?, ?)` + ).run( + submissionId, + assignment.id, + request.auth.user.id, + JSON.stringify(input.answers), + nextStatus, + submittedAt, + timestamp, + timestamp + ) + } + + if (nextStatus === 'submitted') { + db.prepare( + `INSERT INTO notifications ( + id, user_id, type, title, body, resource_type, resource_id, + link, dedupe_key, created_at + ) VALUES (?, ?, 'assignment.submitted', ?, ?, 'submission', ?, ?, ?, ?)` + ).run( + randomUUID(), + assignment.teacher_id, + '有新的作业提交', + `学生提交了作业「${assignment.title}」。`, + submissionId, + `/teacher/courseDetails?assignmentId=${assignment.id}`, + `submission:${submissionId}:submitted:${assignment.teacher_id}`, + timestamp + ) + insertAudit(db, request, { + action: 'submission.submitted', + entityType: 'submission', + entityId: submissionId, + details: { assignmentId: assignment.id } + }) + } + + return { submissionId, created: !existing } + }) + + const outcome = save() + if (outcome.error === 'NOT_FOUND') { + return responseError(reply, 404, '作业不存在') + } + if (outcome.error === 'NOT_AVAILABLE') { + return responseError(reply, 409, '作业当前不可提交', { + assignmentStatus: outcome.status, + courseStatus: outcome.courseStatus + }) + } + if (outcome.error === 'ASSIGNMENT_CLOSED') { + return responseError(reply, 409, '作业已关闭,不能继续保存或提交') + } + if (outcome.error === 'COURSE_ARCHIVED') { + return responseError(reply, 409, '课程已归档,不能继续保存或提交') + } + if (outcome.error === 'DEADLINE_PASSED') { + return responseError(reply, 409, '作业已超过截止时间,不能提交', { + dueAt: outcome.dueAt, + draftPolicy: '已存在的草稿仍可保存,但不能正式提交' + }) + } + if (outcome.error === 'DEADLINE_PASSED_NEW_DRAFT') { + return responseError(reply, 409, '作业已超过截止时间,不能新建草稿', { + dueAt: outcome.dueAt, + draftPolicy: '只有截止时间前已创建的草稿可以继续保存' + }) + } + if (outcome.error === 'INVALID_ANSWERS') { + return responseError(reply, 400, '答案内容不正确', { + errors: outcome.issues + }) + } + if (outcome.error === 'INVALID_STATE') { + return responseError(reply, 409, '作业已经提交,不能重复修改', { + currentStatus: outcome.status + }) + } + if (outcome.error === 'STATE_CHANGED') { + return responseError(reply, 409, '提交状态已变化,请刷新后重试') + } + + const submission = db + .prepare('SELECT * FROM submissions WHERE id = ? LIMIT 1') + .get(outcome.submissionId) + return responseData( + reply, + submissionData(submission), + input.action === 'submit' ? '作业已提交' : '作业草稿已保存', + outcome.created ? 201 : 200 + ) + } + ) + + app.get( + '/api/v1/me/submissions', + { preHandler: app.requireRole('student') }, + async (request, reply) => { + const queryResult = submissionListQuerySchema.safeParse( + request.query ?? {} + ) + if (!queryResult.success) return validationError(reply, queryResult) + + const { page, pageSize, status } = queryResult.data + const conditions = ['s.student_id = ?'] + const parameters = [request.auth.user.id] + if (status) { + conditions.push('s.status = ?') + parameters.push(status) + } + const where = `WHERE ${conditions.join(' AND ')}` + const total = db + .prepare(`SELECT COUNT(*) AS count FROM submissions AS s ${where}`) + .get(...parameters).count + const rows = db + .prepare( + `SELECT + s.*, + a.title AS assignment_title, + a.course_id, + a.due_at, + a.max_score, + c.title AS course_title + FROM submissions AS s + INNER JOIN assignments AS a ON a.id = s.assignment_id + INNER JOIN courses AS c ON c.id = a.course_id + ${where} + ORDER BY s.updated_at DESC, s.id ASC + LIMIT ? OFFSET ?` + ) + .all(...parameters, pageSize, (page - 1) * pageSize) + + return responseData(reply, { + items: rows.map((row) => submissionData(row)), + pagination: pagination(page, pageSize, total) + }) + } + ) + + app.get( + '/api/v1/assignments/:id/submissions', + { preHandler: app.requireRole('teacher') }, + async (request, reply) => { + const paramsResult = assignmentIdParamsSchema.safeParse(request.params) + if (!paramsResult.success) return validationError(reply, paramsResult) + const queryResult = teacherSubmissionListQuerySchema.safeParse( + request.query ?? {} + ) + if (!queryResult.success) return validationError(reply, queryResult) + + const assignment = findAssignment(db, paramsResult.data.id) + if (!assignment) return responseError(reply, 404, '作业不存在') + if (assignment.teacher_id !== request.auth.user.id) { + return responseError(reply, 403, '只能查看自己作业的提交记录') + } + + const { page, pageSize, status } = queryResult.data + const conditions = [ + 's.assignment_id = ?', + "s.status IN ('submitted', 'graded')" + ] + const parameters = [assignment.id] + if (status) { + conditions.push('s.status = ?') + parameters.push(status) + } + const where = `WHERE ${conditions.join(' AND ')}` + const total = db + .prepare(`SELECT COUNT(*) AS count FROM submissions AS s ${where}`) + .get(...parameters).count + const rows = db + .prepare( + `SELECT + s.*, + u.display_name AS student_display_name, + u.avatar_url AS student_avatar_url + FROM submissions AS s + INNER JOIN users AS u ON u.id = s.student_id + ${where} + ORDER BY + s.status = 'submitted' DESC, + s.submitted_at DESC, + s.updated_at DESC, + s.id ASC + LIMIT ? OFFSET ?` + ) + .all(...parameters, pageSize, (page - 1) * pageSize) + + return responseData(reply, { + assignment: assignmentSummary(assignment), + items: rows.map((row) => submissionData(row, { includeStudent: true })), + pagination: pagination(page, pageSize, total) + }) + } + ) + + app.patch( + '/api/v1/submissions/:id/grade', + { preHandler: app.requireRole('teacher') }, + async (request, reply) => { + const paramsResult = submissionIdParamsSchema.safeParse(request.params) + if (!paramsResult.success) return validationError(reply, paramsResult) + + const bodyResult = gradeSubmissionSchema.safeParse( + unwrapBody(request.body) + ) + if (!bodyResult.success) return validationError(reply, bodyResult) + + const input = bodyResult.data + const grade = db.transaction(() => { + const submission = db + .prepare( + `SELECT + s.*, + a.teacher_id, + a.title AS assignment_title, + a.max_score + FROM submissions AS s + INNER JOIN assignments AS a ON a.id = s.assignment_id + WHERE s.id = ? + LIMIT 1` + ) + .get(paramsResult.data.id) + if (!submission) return { error: 'NOT_FOUND' } + if (submission.teacher_id !== request.auth.user.id) { + return { error: 'FORBIDDEN' } + } + if (submission.status !== 'submitted') { + return { error: 'INVALID_STATE', status: submission.status } + } + if (input.score > Number(submission.max_score)) { + return { + error: 'SCORE_TOO_HIGH', + maxScore: Number(submission.max_score) + } + } + + const timestamp = new Date().toISOString() + const updated = db + .prepare( + `UPDATE submissions + SET status = 'graded', score = ?, feedback = ?, graded_by = ?, + graded_at = ?, updated_at = ? + WHERE id = ? AND status = 'submitted'` + ) + .run( + input.score, + input.feedback, + request.auth.user.id, + timestamp, + timestamp, + submission.id + ) + if (updated.changes !== 1) return { error: 'STATE_CHANGED' } + + db.prepare( + `INSERT INTO notifications ( + id, user_id, type, title, body, resource_type, resource_id, + link, dedupe_key, created_at + ) VALUES (?, ?, 'submission.graded', ?, ?, 'submission', ?, ?, ?, ?)` + ).run( + randomUUID(), + submission.student_id, + '作业已批阅', + `作业「${submission.assignment_title}」已批阅,得分 ${input.score}/${Number(submission.max_score)}。`, + submission.id, + `/student/homeWork?submissionId=${submission.id}`, + `submission:${submission.id}:graded:${submission.student_id}`, + timestamp + ) + insertAudit(db, request, { + action: 'submission.graded', + entityType: 'submission', + entityId: submission.id, + details: { + assignmentId: submission.assignment_id, + score: input.score, + maxScore: Number(submission.max_score) + } + }) + + return { submissionId: submission.id } + }) + + const outcome = grade() + if (outcome.error === 'NOT_FOUND') { + return responseError(reply, 404, '提交记录不存在') + } + if (outcome.error === 'FORBIDDEN') { + return responseError(reply, 403, '只能批阅自己作业的提交记录') + } + if (outcome.error === 'INVALID_STATE') { + return responseError(reply, 409, '只有已提交状态的作业可以批阅', { + currentStatus: outcome.status, + requiredStatus: 'submitted' + }) + } + if (outcome.error === 'SCORE_TOO_HIGH') { + return responseError(reply, 400, '得分不能超过作业满分', { + maxScore: outcome.maxScore + }) + } + if (outcome.error === 'STATE_CHANGED') { + return responseError(reply, 409, '提交状态已变化,请刷新后重试') + } + + const submission = db + .prepare( + `SELECT + s.*, + u.display_name AS student_display_name, + u.avatar_url AS student_avatar_url + FROM submissions AS s + INNER JOIN users AS u ON u.id = s.student_id + WHERE s.id = ? LIMIT 1` + ) + .get(outcome.submissionId) + return responseData( + reply, + submissionData(submission, { includeStudent: true }), + '作业已批阅' + ) + } + ) +} + +export default assignmentRoutes diff --git a/server/routes/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..fc46f56 --- /dev/null +++ b/server/routes/auth.js @@ -0,0 +1,479 @@ +import { createHmac, randomInt, randomUUID, timingSafeEqual } from 'node:crypto' +import { Buffer } from 'node:buffer' + +import { z } from 'zod' + +import { createMailProvider } from '../services/mail-provider.js' + +import { + hashPassword, + needsPasswordRehash, + verifyPassword +} from '../lib/password.js' +import { + createSession, + extractSessionToken, + publicUser, + revokeSessionByToken, + SESSION_COOKIE_NAME +} from '../lib/session.js' + +const roleSchema = z.enum(['student', 'teacher', 'administrator']) +const publicRegistrationRoleSchema = z.enum(['student', 'teacher']) +const emailSchema = z + .string() + .trim() + .email('请输入有效邮箱地址') + .max(254, '邮箱地址过长') + .transform((email) => email.toLowerCase()) +const passwordSchema = z + .string() + .min(8, '密码至少需要 8 个字符') + .max(128, '密码不能超过 128 个字符') + .regex(/[A-Za-z]/, '密码至少需要包含一个字母') + .regex(/\d/, '密码至少需要包含一个数字') + +const loginSchema = z.object({ + email: emailSchema, + password: z.string().min(1, '请输入密码').max(128, '密码过长'), + role: roleSchema.optional() +}) + +const registerSchema = z.object({ + email: emailSchema, + password: passwordSchema, + role: publicRegistrationRoleSchema.default('student'), + displayName: z.string().trim().min(1).max(80).optional(), + avatarUrl: z.string().trim().url().max(2048).nullable().optional(), + country: z.string().trim().max(80).nullable().optional(), + region: z.string().trim().max(120).nullable().optional(), + age: z.coerce.number().int().min(6).max(120).nullable().optional(), + chineseLevel: z + .enum(['beginner', 'HSK1', 'HSK2', 'HSK3', 'HSK4', 'HSK5', 'HSK6']) + .nullable() + .optional(), + bio: z.string().trim().max(500).optional(), + school: z.string().trim().max(160).optional(), + title: z.string().trim().max(120).optional(), + experienceYears: z.coerce.number().int().min(0).max(80).optional(), + specialties: z.array(z.string().trim().min(1).max(80)).max(20).optional(), + certificates: z.array(z.string().trim().min(1).max(160)).max(20).optional(), + teachingStyle: z.array(z.string().trim().min(1).max(80)).max(20).optional(), + languages: z.array(z.string().trim().min(1).max(80)).max(20).optional(), + verificationCode: z + .string() + .trim() + .regex(/^\d{6}$/, '请输入 6 位验证码') +}) + +const verificationRequestSchema = z.object({ + email: emailSchema, + role: publicRegistrationRoleSchema.default('student') +}) + +const dummyPasswordHashPromise = hashPassword('invalid-password-123456') + +function responseData(reply, data, message = '操作成功', statusCode = 200) { + return reply.code(statusCode).send({ + code: 0, + msg: message, + data + }) +} + +function responseError(reply, statusCode, message, data = null) { + return reply.code(statusCode).send({ + code: statusCode, + msg: message, + data + }) +} + +function validationError(reply, result) { + return responseError(reply, 400, '请求参数不正确', { + errors: result.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message + })) + }) +} + +function unwrapBody(body) { + if (body && typeof body === 'object' && body.data) { + return body.data + } + + return body ?? {} +} + +function sessionMetadata(request) { + return { + userAgent: request.headers['user-agent'] ?? null, + ipAddress: request.ip ?? null + } +} + +function verificationDigest(secret, email, code) { + return createHmac('sha256', secret) + .update(`${email}:${code}`, 'utf8') + .digest('hex') +} + +function safeDigestEqual(left, right) { + try { + const leftBuffer = Buffer.from(left, 'hex') + const rightBuffer = Buffer.from(right, 'hex') + return ( + leftBuffer.length === rightBuffer.length && + timingSafeEqual(leftBuffer, rightBuffer) + ) + } catch { + return false + } +} + +export async function authRoutes(app) { + const db = app.db + if (!db) { + throw new Error('authRoutes requires app.db') + } + + const isProduction = app.config?.isProduction === true + const verificationSecret = + app.config?.verificationCodeSecret || 'local-development-only-secret' + const mailProvider = createMailProvider({ + smtpUrl: app.config?.smtpUrl, + mailFrom: app.config?.mailFrom + }) + + app.post( + '/api/v1/auth/verification-code', + { + config: { + rateLimit: { + max: 5, + timeWindow: '10 minutes' + } + } + }, + async (request, reply) => { + const result = verificationRequestSchema.safeParse( + unwrapBody(request.body) + ) + if (!result.success) { + return validationError(reply, result) + } + + if ( + isProduction && + (!app.config?.verificationCodeSecret || !app.config?.smtpUrl) + ) { + return responseError(reply, 503, '注册邮件服务尚未配置') + } + + const { email } = result.data + const existingUser = db + .prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1') + .get(email) + if (existingUser) { + return responseError(reply, 409, '该邮箱已注册') + } + + const code = String(randomInt(0, 1_000_000)).padStart(6, '0') + const now = new Date().toISOString() + const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString() + const id = randomUUID() + + const replaceCode = db.transaction(() => { + db.prepare( + `UPDATE verification_codes + SET consumed_at = ? + WHERE email = ? COLLATE NOCASE + AND purpose = 'register' + AND consumed_at IS NULL` + ).run(now, email) + db.prepare( + `INSERT INTO verification_codes ( + id, email, purpose, code_hash, expires_at, created_at + ) VALUES (?, ?, 'register', ?, ?, ?)` + ).run( + id, + email, + verificationDigest(verificationSecret, email, code), + expiresAt, + now + ) + }) + replaceCode() + + if (isProduction) { + try { + await mailProvider.sendVerificationCode({ email, code, expiresAt }) + } catch (error) { + db.prepare( + `UPDATE verification_codes + SET consumed_at = ? WHERE id = ? AND consumed_at IS NULL` + ).run(new Date().toISOString(), id) + request.log.error( + { err: error }, + 'Registration verification email delivery failed' + ) + return responseError(reply, 503, '验证码邮件发送失败,请稍后重试') + } + } + + // Local development intentionally exposes the code so the repository + // remains self-contained. Production sends it through SMTP only. + return responseData( + reply, + isProduction ? { expiresAt } : { expiresAt, developmentCode: code }, + '验证码已发送' + ) + } + ) + + app.post( + '/api/v1/auth/register', + { + config: { + rateLimit: { + max: 10, + timeWindow: '1 minute' + } + } + }, + async (request, reply) => { + const rawBody = unwrapBody(request.body) + const result = registerSchema.safeParse({ + ...rawBody, + displayName: rawBody.displayName ?? rawBody.name + }) + + if (!result.success) { + return validationError(reply, result) + } + + const input = result.data + const existingUser = db + .prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1') + .get(input.email) + + if (existingUser) { + return responseError(reply, 409, '该邮箱已注册') + } + + const verification = db + .prepare( + `SELECT id, code_hash, expires_at, attempts + FROM verification_codes + WHERE email = ? COLLATE NOCASE + AND purpose = 'register' + AND consumed_at IS NULL + ORDER BY created_at DESC + LIMIT 1` + ) + .get(input.email) + const providedDigest = verificationDigest( + verificationSecret, + input.email, + input.verificationCode + ) + const verificationValid = + verification && + verification.expires_at > new Date().toISOString() && + verification.attempts < 10 && + safeDigestEqual(providedDigest, verification.code_hash) + + if (!verificationValid) { + if (verification) { + db.prepare( + `UPDATE verification_codes + SET attempts = MIN(attempts + 1, 10) + WHERE id = ?` + ).run(verification.id) + } + return responseError(reply, 400, '验证码无效或已过期') + } + + const userId = randomUUID() + const createdAt = new Date().toISOString() + const passwordHash = await hashPassword(input.password) + let session + + try { + const createUserAndSession = db.transaction(() => { + db.prepare( + `INSERT INTO users ( + id, + email, + password_hash, + role, + display_name, + avatar_url, + country, + region, + age, + chinese_level, + bio, + status, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)` + ).run( + userId, + input.email, + passwordHash, + input.role, + input.displayName ?? input.email.split('@')[0], + input.avatarUrl ?? null, + input.country ?? null, + input.region ?? null, + input.age ?? null, + input.chineseLevel ?? null, + input.bio ?? '', + createdAt, + createdAt + ) + + if (input.role === 'teacher') { + db.prepare( + `INSERT INTO teacher_profiles ( + user_id, school, title, experience_years, + specialties_json, certificates_json, teaching_style_json, + languages_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + userId, + input.school ?? '', + input.title ?? '国际中文教师', + input.experienceYears ?? 0, + JSON.stringify(input.specialties ?? []), + JSON.stringify(input.certificates ?? []), + JSON.stringify(input.teachingStyle ?? []), + JSON.stringify(input.languages ?? ['中文']), + createdAt, + createdAt + ) + } + + const consumeResult = db + .prepare( + `UPDATE verification_codes + SET consumed_at = ? + WHERE id = ? AND consumed_at IS NULL` + ) + .run(createdAt, verification.id) + if (consumeResult.changes !== 1) { + throw new Error('VERIFICATION_CODE_ALREADY_USED') + } + + return createSession(db, userId, { + ttlSeconds: app.sessionTtlSeconds, + ...sessionMetadata(request) + }) + }) + + session = createUserAndSession() + } catch (error) { + if (error?.message === 'VERIFICATION_CODE_ALREADY_USED') { + return responseError(reply, 409, '验证码已被使用,请重新获取') + } + if ( + error?.code === 'SQLITE_CONSTRAINT_UNIQUE' || + error?.code === 'SQLITE_CONSTRAINT' + ) { + return responseError(reply, 409, '该邮箱已注册') + } + + throw error + } + + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) + reply.setCookie( + SESSION_COOKIE_NAME, + session.token, + app.sessionCookieOptions(session.ttlSeconds) + ) + + return responseData(reply, publicUser(user), '注册成功', 201) + } + ) + + app.post( + '/api/v1/auth/login', + { + config: { + rateLimit: { + max: 20, + timeWindow: '1 minute' + } + } + }, + async (request, reply) => { + const result = loginSchema.safeParse(unwrapBody(request.body)) + if (!result.success) { + return validationError(reply, result) + } + + const input = result.data + const user = db + .prepare('SELECT * FROM users WHERE email = ? COLLATE NOCASE LIMIT 1') + .get(input.email) + const hashToCheck = + user?.password_hash ?? (await dummyPasswordHashPromise) + const passwordMatches = await verifyPassword(input.password, hashToCheck) + + if ( + !user || + !passwordMatches || + user.status !== 'active' || + (input.role && input.role !== user.role) + ) { + return responseError(reply, 401, '邮箱、密码或身份不正确') + } + + if (needsPasswordRehash(user.password_hash)) { + const replacementHash = await hashPassword(input.password) + db.prepare( + 'UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?' + ).run(replacementHash, new Date().toISOString(), user.id) + } + + const session = createSession(db, user.id, { + ttlSeconds: app.sessionTtlSeconds, + ...sessionMetadata(request) + }) + + reply.setCookie( + SESSION_COOKIE_NAME, + session.token, + app.sessionCookieOptions(session.ttlSeconds) + ) + + return responseData(reply, publicUser(user), '登录成功') + } + ) + + app.post('/api/v1/auth/logout', async (request, reply) => { + const credential = extractSessionToken(request) + if (credential) { + revokeSessionByToken(db, credential.token) + } + + reply.clearCookie(SESSION_COOKIE_NAME, { + ...app.sessionCookieOptions(), + maxAge: 0 + }) + + return responseData(reply, null, '已安全退出') + }) + + app.get( + '/api/v1/auth/session', + { preHandler: app.authenticate }, + async (request, reply) => + responseData(reply, request.auth.user, '登录状态有效') + ) +} + +export default authRoutes diff --git a/server/routes/courses.js b/server/routes/courses.js new file mode 100644 index 0000000..9ad6f40 --- /dev/null +++ b/server/routes/courses.js @@ -0,0 +1,558 @@ +import { randomUUID } from 'node:crypto' + +import { z } from 'zod' + +import { SESSION_COOKIE_NAME } from '../lib/session.js' + +const courseStatuses = ['draft', 'pending', 'published', 'rejected', 'archived'] + +const courseStatusSchema = z.enum(courseStatuses) +const courseLevelSchema = z.enum([ + 'beginner', + 'elementary', + 'intermediate', + 'advanced', + 'all' +]) + +const nullablePathSchema = z + .union([z.string().trim().max(2048), z.null()]) + .transform((value) => (value === '' ? null : value)) + +const courseFields = { + title: z.string().trim().min(1, '课程名称不能为空').max(160), + summary: z.string().trim().max(500).default(''), + description: z.string().trim().max(30000).default(''), + level: courseLevelSchema.default('beginner'), + category: z.string().trim().min(1).max(80).default('general'), + coverUrl: nullablePathSchema.optional(), + durationMinutes: z.coerce.number().int().min(1).max(1440).default(60), + priceCents: z.coerce.number().int().min(0).max(100000000).default(0), + capacity: z.coerce.number().int().min(1).max(10000).default(30) +} + +const createCourseSchema = z.object(courseFields).strict() + +const updateCourseSchema = z + .object({ + title: courseFields.title.optional(), + summary: z.string().trim().max(500).optional(), + description: z.string().trim().max(30000).optional(), + level: courseLevelSchema.optional(), + category: z.string().trim().min(1).max(80).optional(), + coverUrl: nullablePathSchema.optional(), + durationMinutes: z.coerce.number().int().min(1).max(1440).optional(), + priceCents: z.coerce.number().int().min(0).max(100000000).optional(), + capacity: z.coerce.number().int().min(1).max(10000).optional() + }) + .strict() + .refine((value) => Object.keys(value).length > 0, { + message: '至少需要提供一个可修改字段' + }) + +const listCoursesQuerySchema = z + .object({ + page: z.coerce.number().int().min(1).max(100000).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(12), + search: z.string().trim().max(100).optional(), + category: z.string().trim().min(1).max(80).optional(), + status: courseStatusSchema.optional() + }) + .strict() + +const courseIdParamsSchema = z.object({ + id: z.string().uuid('课程 ID 格式不正确') +}) + +const courseSelect = ` + SELECT + c.*, + u.display_name AS teacher_name, + u.avatar_url AS teacher_avatar_url, + u.status AS teacher_account_status, + tp.school AS teacher_school, + tp.title AS teacher_title, + tp.rating AS teacher_rating, + tp.verified_at AS teacher_verified_at + FROM courses AS c + INNER JOIN users AS u ON u.id = c.teacher_id + LEFT JOIN teacher_profiles AS tp ON tp.user_id = c.teacher_id +` + +function responseData(reply, data, message = '操作成功', statusCode = 200) { + return reply.code(statusCode).send({ code: 0, msg: message, data }) +} + +function responseError(reply, statusCode, message, data = null) { + return reply.code(statusCode).send({ + code: statusCode, + msg: message, + data + }) +} + +function validationError(reply, result) { + return responseError(reply, 400, '请求参数不正确', { + errors: result.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message + })) + }) +} + +function unwrapBody(body) { + if (body && typeof body === 'object' && body.data) { + return body.data + } + + return body ?? {} +} + +function escapeLike(value) { + return value.replace(/[\\%_]/g, '\\$&') +} + +function pagination(page, pageSize, total) { + return { + page, + pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / pageSize) + } +} + +export function courseFromRow(row) { + if (!row) return null + + return { + id: row.id, + teacherId: row.teacher_id, + teacher: { + id: row.teacher_id, + displayName: row.teacher_name ?? '', + avatarUrl: row.teacher_avatar_url ?? null, + school: row.teacher_school ?? '', + title: row.teacher_title ?? '', + rating: row.teacher_rating ?? null + }, + title: row.title, + summary: row.summary, + description: row.description, + level: row.level, + category: row.category, + coverUrl: row.cover_url ?? null, + durationMinutes: row.duration_minutes, + priceCents: row.price_cents, + capacity: row.capacity, + status: row.status, + rejectionReason: row.rejection_reason ?? null, + publishedAt: row.published_at ?? null, + createdAt: row.created_at, + updatedAt: row.updated_at + } +} + +export function reviewFromRow(row) { + if (!row) return null + + return { + id: row.id, + courseId: row.course_id, + reviewerId: row.reviewer_id, + decision: row.decision, + note: row.review_note, + createdAt: row.created_at + } +} + +function hasAuthenticationCredential(request) { + return Boolean( + request.cookies?.[SESSION_COOKIE_NAME] || request.headers.authorization + ) +} + +function optionalAuthentication(app) { + return async (request, reply) => { + if (hasAuthenticationCredential(request)) { + await app.authenticate(request, reply) + } + } +} + +function findCourse(db, courseId) { + return db.prepare(`${courseSelect} WHERE c.id = ? LIMIT 1`).get(courseId) +} + +function canReadUnpublishedCourse(request, course) { + const user = request.auth?.user + if (!user) return false + if (user.role === 'administrator') return true + + return user.role === 'teacher' && user.id === course.teacher_id +} + +function isPubliclyVisibleCourse(course) { + return ( + course.status === 'published' && + course.teacher_account_status === 'active' && + Boolean(course.teacher_verified_at) + ) +} + +export async function courseRoutes(app) { + const db = app.db + if (!db) { + throw new Error('courseRoutes requires app.db') + } + + app.get( + '/api/v1/courses', + { preHandler: optionalAuthentication(app) }, + async (request, reply) => { + const result = listCoursesQuerySchema.safeParse(request.query ?? {}) + if (!result.success) { + return validationError(reply, result) + } + + const { page, pageSize, search, category, status } = result.data + const user = request.auth?.user + const where = [] + const parameters = [] + + if (!user || user.role === 'student') { + where.push( + "c.status = 'published'", + "u.status = 'active'", + 'tp.verified_at IS NOT NULL' + ) + } else if (user.role === 'teacher') { + where.push('c.teacher_id = ?') + parameters.push(user.id) + if (status) { + where.push('c.status = ?') + parameters.push(status) + } + } else if (status) { + where.push('c.status = ?') + parameters.push(status) + } + + if (category) { + where.push('c.category = ?') + parameters.push(category) + } + + if (search) { + const pattern = `%${escapeLike(search)}%` + where.push( + "(c.title LIKE ? ESCAPE '\\' OR c.summary LIKE ? ESCAPE '\\' OR u.display_name LIKE ? ESCAPE '\\')" + ) + parameters.push(pattern, pattern, pattern) + } + + const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '' + const total = db + .prepare( + `SELECT COUNT(*) AS count + FROM courses AS c + INNER JOIN users AS u ON u.id = c.teacher_id + LEFT JOIN teacher_profiles AS tp ON tp.user_id = c.teacher_id + ${whereClause}` + ) + .get(...parameters).count + const items = db + .prepare( + `${courseSelect} + ${whereClause} + ORDER BY + CASE c.status + WHEN 'pending' THEN 0 + WHEN 'rejected' THEN 1 + WHEN 'draft' THEN 2 + WHEN 'published' THEN 3 + ELSE 4 + END, + COALESCE(c.published_at, c.updated_at) DESC, + c.id DESC + LIMIT ? OFFSET ?` + ) + .all(...parameters, pageSize, (page - 1) * pageSize) + .map(courseFromRow) + + return responseData(reply, { + items, + pagination: pagination(page, pageSize, total) + }) + } + ) + + app.get( + '/api/v1/courses/:id', + { preHandler: optionalAuthentication(app) }, + async (request, reply) => { + const paramsResult = courseIdParamsSchema.safeParse(request.params) + if (!paramsResult.success) { + return validationError(reply, paramsResult) + } + + const course = findCourse(db, paramsResult.data.id) + if (!course) { + return responseError(reply, 404, '课程不存在') + } + + if ( + !isPubliclyVisibleCourse(course) && + !canReadUnpublishedCourse(request, course) + ) { + if (request.auth?.user?.role === 'teacher') { + return responseError(reply, 403, '没有权限访问该课程') + } + + return responseError(reply, 404, '课程不存在') + } + + const data = courseFromRow(course) + if (canReadUnpublishedCourse(request, course)) { + const latestReview = db + .prepare( + `SELECT * FROM course_reviews + WHERE course_id = ? + ORDER BY created_at DESC, id DESC + LIMIT 1` + ) + .get(course.id) + data.latestReview = reviewFromRow(latestReview) + } + + return responseData(reply, data) + } + ) + + app.post( + '/api/v1/courses', + { preHandler: app.requireRole('teacher') }, + async (request, reply) => { + const result = createCourseSchema.safeParse(unwrapBody(request.body)) + if (!result.success) { + return validationError(reply, result) + } + + const input = result.data + const id = randomUUID() + const now = new Date().toISOString() + db.prepare( + `INSERT INTO courses ( + id, teacher_id, title, summary, description, level, category, + cover_url, duration_minutes, price_cents, capacity, status, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?)` + ).run( + id, + request.auth.user.id, + input.title, + input.summary, + input.description, + input.level, + input.category, + input.coverUrl ?? null, + input.durationMinutes, + input.priceCents, + input.capacity, + now, + now + ) + + return responseData( + reply, + courseFromRow(findCourse(db, id)), + '课程草稿已创建', + 201 + ) + } + ) + + app.patch( + '/api/v1/courses/:id', + { preHandler: app.requireRole('teacher') }, + async (request, reply) => { + const paramsResult = courseIdParamsSchema.safeParse(request.params) + if (!paramsResult.success) { + return validationError(reply, paramsResult) + } + + const result = updateCourseSchema.safeParse(unwrapBody(request.body)) + if (!result.success) { + return validationError(reply, result) + } + + const course = findCourse(db, paramsResult.data.id) + if (!course) { + return responseError(reply, 404, '课程不存在') + } + if (course.teacher_id !== request.auth.user.id) { + return responseError(reply, 403, '只能修改自己的课程') + } + if (!['draft', 'rejected'].includes(course.status)) { + return responseError(reply, 409, '当前状态不允许修改课程', { + currentStatus: course.status, + allowedStatuses: ['draft', 'rejected'] + }) + } + + const columnMap = { + title: 'title', + summary: 'summary', + description: 'description', + level: 'level', + category: 'category', + coverUrl: 'cover_url', + durationMinutes: 'duration_minutes', + priceCents: 'price_cents', + capacity: 'capacity' + } + const assignments = [] + const values = [] + + for (const [field, column] of Object.entries(columnMap)) { + if (result.data[field] !== undefined) { + assignments.push(`${column} = ?`) + values.push(result.data[field]) + } + } + + assignments.push('updated_at = ?') + values.push(new Date().toISOString(), course.id) + db.prepare( + `UPDATE courses SET ${assignments.join(', ')} WHERE id = ?` + ).run(...values) + + return responseData( + reply, + courseFromRow(findCourse(db, course.id)), + '课程已更新' + ) + } + ) + + app.post( + '/api/v1/courses/:id/submit', + { preHandler: app.requireRole('teacher') }, + async (request, reply) => { + const paramsResult = courseIdParamsSchema.safeParse(request.params) + if (!paramsResult.success) { + return validationError(reply, paramsResult) + } + + const course = findCourse(db, paramsResult.data.id) + if (!course) { + return responseError(reply, 404, '课程不存在') + } + if (course.teacher_id !== request.auth.user.id) { + return responseError(reply, 403, '只能提交自己的课程') + } + if (!['draft', 'rejected'].includes(course.status)) { + return responseError(reply, 409, '当前状态不允许提交审核', { + currentStatus: course.status, + allowedStatuses: ['draft', 'rejected'] + }) + } + + const verifiedTeacher = db + .prepare( + `SELECT 1 + FROM teacher_profiles + WHERE user_id = ? AND verified_at IS NOT NULL + LIMIT 1` + ) + .get(request.auth.user.id) + if (!verifiedTeacher) { + return responseError( + reply, + 409, + '教师身份通过平台认证后才能提交课程审核', + { + verificationStatus: 'pending' + } + ) + } + + const submittedAt = new Date().toISOString() + const auditId = randomUUID() + const notifyAdministrators = db.transaction(() => { + const updated = db + .prepare( + `UPDATE courses + SET status = 'pending', rejection_reason = NULL, updated_at = ? + WHERE id = ? AND teacher_id = ? AND status IN ('draft', 'rejected')` + ) + .run(submittedAt, course.id, request.auth.user.id) + + if (updated.changes !== 1) { + throw new Error('COURSE_STATE_CHANGED') + } + + db.prepare( + `INSERT INTO audit_logs ( + id, actor_id, action, entity_type, entity_id, details_json, + request_id, ip_address, user_agent, created_at + ) VALUES (?, ?, 'course.submitted', 'course', ?, ?, ?, ?, ?, ?)` + ).run( + auditId, + request.auth.user.id, + course.id, + JSON.stringify({ + previousStatus: course.status, + nextStatus: 'pending' + }), + request.id ?? null, + request.ip ?? null, + request.headers['user-agent'] ?? null, + submittedAt + ) + + const administrators = db + .prepare( + "SELECT id FROM users WHERE role = 'administrator' AND status = 'active'" + ) + .all() + const insertNotification = db.prepare( + `INSERT INTO notifications ( + id, user_id, type, title, body, resource_type, resource_id, link, + dedupe_key, created_at + ) VALUES (?, ?, 'course.submitted', ?, ?, 'course', ?, ?, ?, ?)` + ) + + for (const administrator of administrators) { + const notificationId = randomUUID() + insertNotification.run( + notificationId, + administrator.id, + '有新课程待审核', + `课程「${course.title}」已提交审核。`, + course.id, + `/administrator/auditCenter?courseId=${course.id}`, + `course:${course.id}:submitted:${auditId}:${administrator.id}`, + submittedAt + ) + } + }) + + try { + notifyAdministrators() + } catch (error) { + if (error?.message === 'COURSE_STATE_CHANGED') { + return responseError(reply, 409, '课程状态已变化,请刷新后重试') + } + throw error + } + + return responseData( + reply, + courseFromRow(findCourse(db, course.id)), + '课程已提交审核' + ) + } + ) +} + +export default courseRoutes diff --git a/server/routes/dialogues.js b/server/routes/dialogues.js new file mode 100644 index 0000000..66f4b66 --- /dev/null +++ b/server/routes/dialogues.js @@ -0,0 +1,321 @@ +import { randomUUID } from 'node:crypto' + +import { z } from 'zod' + +import { createDialogueProvider } from '../services/dialogue-provider.js' + +const idSchema = z + .object({ id: z.string().uuid('对话 ID 格式不正确') }) + .strict() +const listSchema = z + .object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(50).default(12) + }) + .strict() +const createSchema = z + .object({ + title: z.string().trim().min(1).max(160).optional(), + keywords: z + .array(z.string().trim().min(1).max(40)) + .min(1) + .max(5) + .refine((items) => new Set(items).size === items.length, '关键词不能重复') + }) + .strict() +const messageSchema = z + .object({ message: z.string().trim().min(1).max(2000) }) + .strict() + +function responseData(reply, data, message = '操作成功', statusCode = 200) { + return reply.code(statusCode).send({ code: 0, msg: message, data }) +} + +function responseError(reply, statusCode, message, data = null) { + return reply.code(statusCode).send({ code: statusCode, msg: message, data }) +} + +function validationError(reply, result) { + return responseError(reply, 400, '请求参数不正确', { + errors: result.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message + })) + }) +} + +function unwrapBody(body) { + if (body && typeof body === 'object' && body.data) return body.data + return body ?? {} +} + +function parseKeywords(value) { + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +function turnData(row) { + return { + id: row.id, + position: Number(row.position), + speaker: row.speaker, + content: row.content, + createdAt: row.created_at + } +} + +function sessionData(row) { + return { + id: row.id, + title: row.title, + keywords: parseKeywords(row.keywords_json), + provider: row.provider, + turnCount: Number(row.turn_count ?? 0), + lastMessage: row.last_message ?? null, + createdAt: row.created_at, + updatedAt: row.updated_at + } +} + +function findOwnedSession(db, sessionId, studentId) { + return db + .prepare( + `SELECT * FROM dialogue_sessions + WHERE id = ? AND student_id = ? LIMIT 1` + ) + .get(sessionId, studentId) +} + +function getTurns(db, sessionId) { + return db + .prepare( + `SELECT * FROM dialogue_turns + WHERE session_id = ? ORDER BY position ASC` + ) + .all(sessionId) +} + +export async function dialogueRoutes(app) { + const db = app.db + if (!db) throw new Error('dialogueRoutes requires app.db') + + const provider = createDialogueProvider({ + apiUrl: app.config?.aiApiUrl, + apiKey: app.config?.aiApiKey, + logger: app.log + }) + + app.get( + '/api/v1/dialogues', + { preHandler: app.requireRole('student') }, + async (request, reply) => { + const result = listSchema.safeParse(request.query ?? {}) + if (!result.success) return validationError(reply, result) + const { page, pageSize } = result.data + const studentId = request.auth.user.id + const total = Number( + db + .prepare( + 'SELECT COUNT(*) AS count FROM dialogue_sessions WHERE student_id = ?' + ) + .get(studentId).count + ) + const rows = db + .prepare( + `SELECT + s.*, + COUNT(t.id) AS turn_count, + ( + SELECT content FROM dialogue_turns + WHERE session_id = s.id + ORDER BY position DESC LIMIT 1 + ) AS last_message + FROM dialogue_sessions AS s + LEFT JOIN dialogue_turns AS t ON t.session_id = s.id + WHERE s.student_id = ? + GROUP BY s.id + ORDER BY s.updated_at DESC, s.id ASC + LIMIT ? OFFSET ?` + ) + .all(studentId, pageSize, (page - 1) * pageSize) + + return responseData(reply, { + items: rows.map(sessionData), + pagination: { + page, + pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / pageSize) + } + }) + } + ) + + app.post( + '/api/v1/dialogues', + { preHandler: app.requireRole('student') }, + async (request, reply) => { + const result = createSchema.safeParse(unwrapBody(request.body)) + if (!result.success) return validationError(reply, result) + + const generated = await provider.generate(result.data) + const sessionId = randomUUID() + const timestamp = new Date().toISOString() + const create = db.transaction(() => { + db.prepare( + `INSERT INTO dialogue_sessions ( + id, student_id, title, keywords_json, provider, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run( + sessionId, + request.auth.user.id, + generated.title, + JSON.stringify(result.data.keywords), + generated.provider, + timestamp, + timestamp + ) + + const insertTurn = db.prepare( + `INSERT INTO dialogue_turns ( + id, session_id, position, speaker, content, created_at + ) VALUES (?, ?, ?, ?, ?, ?)` + ) + generated.turns.forEach((turn, index) => { + insertTurn.run( + randomUUID(), + sessionId, + index + 1, + turn.speaker, + turn.content, + timestamp + ) + }) + }) + create() + + const session = findOwnedSession(db, sessionId, request.auth.user.id) + return responseData( + reply, + { + ...sessionData(session), + turns: getTurns(db, sessionId).map(turnData) + }, + '对话练习已生成', + 201 + ) + } + ) + + app.get( + '/api/v1/dialogues/:id', + { preHandler: app.requireRole('student') }, + async (request, reply) => { + const result = idSchema.safeParse(request.params) + if (!result.success) return validationError(reply, result) + + const session = findOwnedSession(db, result.data.id, request.auth.user.id) + if (!session) return responseError(reply, 404, '对话练习不存在') + const turns = getTurns(db, session.id) + return responseData(reply, { + ...sessionData({ ...session, turn_count: turns.length }), + turns: turns.map(turnData) + }) + } + ) + + app.post( + '/api/v1/dialogues/:id/messages', + { + preHandler: app.requireRole('student'), + config: { rateLimit: { max: 20, timeWindow: '1 minute' } } + }, + async (request, reply) => { + const paramsResult = idSchema.safeParse(request.params) + if (!paramsResult.success) return validationError(reply, paramsResult) + const bodyResult = messageSchema.safeParse(unwrapBody(request.body)) + if (!bodyResult.success) return validationError(reply, bodyResult) + + const session = findOwnedSession( + db, + paramsResult.data.id, + request.auth.user.id + ) + if (!session) return responseError(reply, 404, '对话练习不存在') + + const turns = getTurns(db, session.id) + if (turns.length >= 100) { + return responseError(reply, 409, '本轮对话已达到上限,请创建新的练习') + } + const generated = await provider.reply({ + keywords: parseKeywords(session.keywords_json), + history: turns + .slice(-20) + .map(({ speaker, content }) => ({ speaker, content })), + message: bodyResult.data.message + }) + const timestamp = new Date().toISOString() + const append = db.transaction(() => { + const current = findOwnedSession(db, session.id, request.auth.user.id) + if (!current) return null + const position = Number( + db + .prepare( + `SELECT COALESCE(MAX(position), 0) AS position + FROM dialogue_turns WHERE session_id = ?` + ) + .get(session.id).position + ) + const studentTurn = { + id: randomUUID(), + position: position + 1, + speaker: 'student', + content: bodyResult.data.message + } + const tutorTurn = { + id: randomUUID(), + position: position + 2, + speaker: 'tutor', + content: generated.content + } + const insert = db.prepare( + `INSERT INTO dialogue_turns ( + id, session_id, position, speaker, content, created_at + ) VALUES (?, ?, ?, ?, ?, ?)` + ) + for (const turn of [studentTurn, tutorTurn]) { + insert.run( + turn.id, + session.id, + turn.position, + turn.speaker, + turn.content, + timestamp + ) + } + db.prepare( + `UPDATE dialogue_sessions + SET provider = ?, updated_at = ? WHERE id = ?` + ).run(generated.provider, timestamp, session.id) + return { studentTurn, tutorTurn } + }) + + const appended = append() + if (!appended) + return responseError(reply, 409, '对话状态已变化,请刷新重试') + return responseData(reply, { + provider: generated.provider, + turns: [ + { ...appended.studentTurn, createdAt: timestamp }, + { ...appended.tutorTurn, createdAt: timestamp } + ] + }) + } + ) +} + +export default dialogueRoutes diff --git a/server/routes/files.js b/server/routes/files.js new file mode 100644 index 0000000..28f5589 --- /dev/null +++ b/server/routes/files.js @@ -0,0 +1,627 @@ +import { createHash, randomUUID } from 'node:crypto' +import { Buffer } from 'node:buffer' +import { basename, extname, join, relative, resolve } from 'node:path' +import { createReadStream } from 'node:fs' +import { mkdir, open, rename, stat, unlink } from 'node:fs/promises' + +import { z } from 'zod' + +const idSchema = z + .object({ id: z.string().uuid('文件 ID 格式不正确') }) + .strict() +const uploadQuerySchema = z + .object({ + category: z.enum([ + 'avatar', + 'course_cover', + 'course_video', + 'course_material' + ]) + }) + .strict() + +const categories = Object.freeze({ + avatar: { + maxBytes: 5 * 1024 * 1024, + mimeTypes: new Set(['image/jpeg', 'image/png', 'image/webp']), + public: true, + roles: null + }, + course_cover: { + maxBytes: 8 * 1024 * 1024, + mimeTypes: new Set(['image/jpeg', 'image/png', 'image/webp']), + public: true, + roles: new Set(['teacher', 'administrator']) + }, + course_video: { + maxBytes: 25 * 1024 * 1024, + mimeTypes: new Set(['video/mp4', 'video/webm']), + public: false, + roles: new Set(['teacher', 'administrator']) + }, + course_material: { + maxBytes: 10 * 1024 * 1024, + mimeTypes: new Set(['application/pdf']), + public: false, + roles: new Set(['teacher', 'administrator']) + } +}) + +const DEFAULT_OWNER_STORAGE_BYTES = 250 * 1024 * 1024 +const DEFAULT_PLATFORM_STORAGE_BYTES = 5 * 1024 * 1024 * 1024 +const DEFAULT_MAX_CONCURRENT_UPLOADS = 4 +const MULTIPART_OVERHEAD_BYTES = 1024 * 1024 +const HEADER_INSPECTION_BYTES = 512 + +const extensions = Object.freeze({ + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'video/mp4': '.mp4', + 'video/webm': '.webm', + 'application/pdf': '.pdf' +}) + +function responseData(reply, data, message = '操作成功', statusCode = 200) { + return reply.code(statusCode).send({ code: 0, msg: message, data }) +} + +function responseError(reply, statusCode, message, data = null) { + return reply.code(statusCode).send({ code: statusCode, msg: message, data }) +} + +function validationError(reply, result) { + return responseError(reply, 400, '请求参数不正确', { + errors: result.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message + })) + }) +} + +function startsWith(buffer, bytes, offset = 0) { + if (buffer.length < offset + bytes.length) return false + return bytes.every((byte, index) => buffer[offset + index] === byte) +} + +function detectMimeType(buffer) { + if ( + startsWith(buffer, [0xff, 0xd8, 0xff]) && + buffer.length >= 4 && + buffer[3] >= 0xc0 && + buffer[3] !== 0xff + ) { + return 'image/jpeg' + } + if ( + startsWith(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) && + startsWith(buffer, [0x49, 0x48, 0x44, 0x52], 12) + ) { + return 'image/png' + } + if ( + startsWith(buffer, [0x52, 0x49, 0x46, 0x46]) && + startsWith(buffer, [0x57, 0x45, 0x42, 0x50], 8) && + ['VP8 ', 'VP8L', 'VP8X'].includes(buffer.subarray(12, 16).toString('ascii')) + ) { + return 'image/webp' + } + if (/^%PDF-[12]\.\d/.test(buffer.subarray(0, 8).toString('ascii'))) { + return 'application/pdf' + } + if ( + buffer.length >= 16 && + startsWith(buffer, [0x66, 0x74, 0x79, 0x70], 4) && + buffer.readUInt32BE(0) >= 16 && + /^[\x20-\x7e]{4}$/.test(buffer.subarray(8, 12).toString('ascii')) + ) { + return 'video/mp4' + } + if ( + startsWith(buffer, [0x1a, 0x45, 0xdf, 0xa3]) && + buffer.indexOf(Buffer.from('webm'), 4) !== -1 + ) { + return 'video/webm' + } + return null +} + +class UploadRejectedError extends Error { + constructor(statusCode, message) { + super(message) + this.name = 'UploadRejectedError' + this.statusCode = statusCode + } +} + +function positiveSafeInteger(value, fallback, name) { + const candidate = value ?? fallback + if (!Number.isSafeInteger(candidate) || candidate < 1) { + throw new TypeError(`${name} must be a positive safe integer`) + } + return candidate +} + +function resolveUploadLimits(config) { + const ownerQuotaBytes = positiveSafeInteger( + config.uploadOwnerQuotaBytes, + DEFAULT_OWNER_STORAGE_BYTES, + 'uploadOwnerQuotaBytes' + ) + const totalQuotaBytes = positiveSafeInteger( + config.uploadTotalQuotaBytes, + DEFAULT_PLATFORM_STORAGE_BYTES, + 'uploadTotalQuotaBytes' + ) + const maxConcurrent = positiveSafeInteger( + config.uploadMaxConcurrent, + DEFAULT_MAX_CONCURRENT_UPLOADS, + 'uploadMaxConcurrent' + ) + if (maxConcurrent > 32) { + throw new TypeError('uploadMaxConcurrent must not exceed 32') + } + if (totalQuotaBytes < ownerQuotaBytes) { + throw new TypeError( + 'uploadTotalQuotaBytes must be greater than or equal to uploadOwnerQuotaBytes' + ) + } + return { ownerQuotaBytes, totalQuotaBytes, maxConcurrent } +} + +function createUploadCoordinator(db, limits) { + let ownerUsage + let platformUsage + const reservedByOwner = new Map() + let activeUploads = 0 + let totalReservedBytes = 0 + + function committedOwnerBytes(ownerId) { + ownerUsage ??= db.prepare( + `SELECT COALESCE(SUM(size_bytes), 0) AS total + FROM files + WHERE owner_id = ?` + ) + return Number(ownerUsage.get(ownerId).total) + } + + function committedPlatformBytes() { + platformUsage ??= db.prepare( + 'SELECT COALESCE(SUM(size_bytes), 0) AS total FROM files' + ) + return Number(platformUsage.get().total) + } + + return { + tryStart(ownerId) { + if (activeUploads >= limits.maxConcurrent) return null + + activeUploads += 1 + let released = false + let tokenReservedBytes = 0 + + function assertOpen() { + if (released) + throw new Error('Upload admission has already been released') + } + + return { + assertCapacityAvailable() { + assertOpen() + const ownerReservedBytes = reservedByOwner.get(ownerId) ?? 0 + if ( + committedOwnerBytes(ownerId) + ownerReservedBytes >= + limits.ownerQuotaBytes + ) { + throw new UploadRejectedError(413, '账户文件存储空间已用尽') + } + if ( + committedPlatformBytes() + totalReservedBytes >= + limits.totalQuotaBytes + ) { + throw new UploadRejectedError(507, '平台文件存储空间已用尽') + } + }, + reserve(bytes) { + assertOpen() + if (!Number.isSafeInteger(bytes) || bytes < 0) { + throw new TypeError('Reserved upload bytes must be a safe integer') + } + + const ownerReservedBytes = reservedByOwner.get(ownerId) ?? 0 + if ( + committedOwnerBytes(ownerId) + ownerReservedBytes + bytes > + limits.ownerQuotaBytes + ) { + throw new UploadRejectedError(413, '账户文件存储空间不足') + } + if ( + committedPlatformBytes() + totalReservedBytes + bytes > + limits.totalQuotaBytes + ) { + throw new UploadRejectedError(507, '平台文件存储空间不足') + } + + reservedByOwner.set(ownerId, ownerReservedBytes + bytes) + totalReservedBytes += bytes + tokenReservedBytes += bytes + }, + assertWithinQuota() { + assertOpen() + const ownerReservedBytes = reservedByOwner.get(ownerId) ?? 0 + if ( + committedOwnerBytes(ownerId) + ownerReservedBytes > + limits.ownerQuotaBytes + ) { + throw new UploadRejectedError(413, '账户文件存储空间不足') + } + if ( + committedPlatformBytes() + totalReservedBytes > + limits.totalQuotaBytes + ) { + throw new UploadRejectedError(507, '平台文件存储空间不足') + } + }, + release() { + if (released) return + released = true + activeUploads -= 1 + totalReservedBytes -= tokenReservedBytes + + const remainingOwnerBytes = + (reservedByOwner.get(ownerId) ?? 0) - tokenReservedBytes + if (remainingOwnerBytes === 0) reservedByOwner.delete(ownerId) + else reservedByOwner.set(ownerId, remainingOwnerBytes) + } + } + } + } +} + +async function writeAll(fileHandle, chunk, position) { + let offset = 0 + while (offset < chunk.length) { + const { bytesWritten } = await fileHandle.write( + chunk, + offset, + chunk.length - offset, + position + offset + ) + if (bytesWritten === 0) + throw new Error('Unable to make progress writing upload') + offset += bytesWritten + } +} + +async function unlinkIfPresent(targetFile) { + try { + await unlink(targetFile) + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } +} + +async function streamPartToFile(part, targetFile, maxBytes, admission) { + const hash = createHash('sha256') + const header = Buffer.alloc(HEADER_INSPECTION_BYTES) + let headerLength = 0 + let sizeBytes = 0 + let fileHandle + let created = false + + try { + fileHandle = await open(targetFile, 'wx', 0o600) + created = true + + for await (const value of part.file) { + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value) + if (sizeBytes + chunk.length > maxBytes) { + throw new UploadRejectedError(413, '文件超过该类型允许的大小') + } + + admission.reserve(chunk.length) + hash.update(chunk) + + if (headerLength < HEADER_INSPECTION_BYTES) { + const bytesToCopy = Math.min( + chunk.length, + HEADER_INSPECTION_BYTES - headerLength + ) + chunk.copy(header, headerLength, 0, bytesToCopy) + headerLength += bytesToCopy + } + + await writeAll(fileHandle, chunk, sizeBytes) + sizeBytes += chunk.length + } + + if (part.file.truncated) { + throw new UploadRejectedError(413, '文件超过该类型允许的大小') + } + if (sizeBytes === 0) throw new UploadRejectedError(400, '文件不能为空') + + await fileHandle.sync() + await fileHandle.close() + fileHandle = null + return { + header: header.subarray(0, headerLength), + sha256: hash.digest('hex'), + sizeBytes + } + } catch (error) { + if (fileHandle) await fileHandle.close().catch(() => {}) + if (created) await unlinkIfPresent(targetFile) + throw error + } +} + +function safeOriginalName(value) { + const candidate = basename(String(value || 'upload').normalize('NFC')) + .split('') + .filter((character) => { + const codePoint = character.codePointAt(0) + return codePoint > 31 && codePoint !== 127 + }) + .join('') + .trim() + if (!candidate) return 'upload' + if (candidate.length <= 255) return candidate + + const extension = extname(candidate).slice(0, 20) + return `${candidate.slice(0, Math.max(1, 255 - extension.length))}${extension}` +} + +function fileData(row) { + return { + id: row.id, + ownerId: row.owner_id, + originalName: row.original_name, + mimeType: row.mime_type, + sizeBytes: Number(row.size_bytes), + sha256: row.sha256, + category: row.category, + url: `/api/v1/files/${row.id}/content`, + createdAt: row.created_at + } +} + +function resolveStoredFile(uploadDir, storageKey) { + const root = resolve(uploadDir) + const target = resolve(root, storageKey) + const pathWithinRoot = relative(root, target) + if ( + !pathWithinRoot || + pathWithinRoot.startsWith('..') || + pathWithinRoot.includes(':') + ) { + return null + } + return target +} + +function contentDisposition(originalName) { + const encoded = encodeURIComponent(originalName).replaceAll("'", '%27') + return `inline; filename*=UTF-8''${encoded}` +} + +export async function fileRoutes(app) { + const db = app.db + if (!db) throw new Error('fileRoutes requires app.db') + + const uploadDir = app.config?.uploadDir + if (!uploadDir) throw new Error('fileRoutes requires app.config.uploadDir') + const uploadLimits = resolveUploadLimits(app.config) + const uploadCoordinator = createUploadCoordinator(db, uploadLimits) + const temporaryUploadDir = join(uploadDir, '.tmp') + + app.post( + '/api/v1/files', + { + preHandler: app.authenticate, + config: { rateLimit: { max: 12, timeWindow: '1 minute' } } + }, + async (request, reply) => { + const queryResult = uploadQuerySchema.safeParse(request.query ?? {}) + if (!queryResult.success) return validationError(reply, queryResult) + + const category = queryResult.data.category + const policy = categories[category] + if (policy.roles && !policy.roles.has(request.auth.user.role)) { + return responseError(reply, 403, '当前角色不能上传该类型文件') + } + + const declaredLength = Number(request.headers['content-length']) + if ( + Number.isFinite(declaredLength) && + declaredLength > policy.maxBytes + MULTIPART_OVERHEAD_BYTES + ) { + return responseError(reply, 413, '文件超过该类型允许的大小') + } + + let admission = uploadCoordinator.tryStart(request.auth.user.id) + if (!admission) { + reply.header('Retry-After', '1') + return responseError(reply, 503, '上传任务繁忙,请稍后重试') + } + + let cleanupFile = null + try { + admission.assertCapacityAvailable() + + const part = await request.file({ + limits: { files: 1, fileSize: policy.maxBytes } + }) + if (!part) throw new UploadRejectedError(400, '请选择需要上传的文件') + + await mkdir(temporaryUploadDir, { recursive: true }) + const temporaryFile = join(temporaryUploadDir, `${randomUUID()}.upload`) + const streamed = await streamPartToFile( + part, + temporaryFile, + policy.maxBytes, + admission + ) + cleanupFile = temporaryFile + + const detectedMimeType = detectMimeType(streamed.header) + if (!detectedMimeType || !policy.mimeTypes.has(detectedMimeType)) { + throw new UploadRejectedError(415, '文件内容与所选类型不匹配') + } + + admission.assertWithinQuota() + + const id = randomUUID() + const storageName = `${randomUUID()}${extensions[detectedMimeType]}` + const storageKey = `${category}/${storageName}` + const targetDir = join(uploadDir, category) + const targetFile = join(targetDir, storageName) + const originalName = safeOriginalName(part.filename) + const createdAt = new Date().toISOString() + + await mkdir(targetDir, { recursive: true }) + await rename(temporaryFile, targetFile) + cleanupFile = targetFile + + db.prepare( + `INSERT INTO files ( + id, owner_id, storage_key, original_name, mime_type, size_bytes, + sha256, category, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + request.auth.user.id, + storageKey, + originalName, + detectedMimeType, + streamed.sizeBytes, + streamed.sha256, + category, + createdAt + ) + cleanupFile = null + admission.release() + admission = null + const row = db.prepare('SELECT * FROM files WHERE id = ?').get(id) + return responseData(reply, fileData(row), '文件已上传', 201) + } catch (error) { + if ( + error instanceof UploadRejectedError || + error?.code === 'FST_REQ_FILE_TOO_LARGE' + ) { + const statusCode = error.statusCode ?? 413 + const message = + error instanceof UploadRejectedError + ? error.message + : '文件超过该类型允许的大小' + if (cleanupFile) { + await unlinkIfPresent(cleanupFile) + cleanupFile = null + } + admission?.release() + admission = null + return responseError(reply, statusCode, message) + } + throw error + } finally { + if (cleanupFile) await unlinkIfPresent(cleanupFile) + admission?.release() + } + } + ) + + app.get('/api/v1/files/:id/content', async (request, reply) => { + const result = idSchema.safeParse(request.params) + if (!result.success) return validationError(reply, result) + + const row = db + .prepare('SELECT * FROM files WHERE id = ? LIMIT 1') + .get(result.data.id) + if (!row) return responseError(reply, 404, '文件不存在') + + const policy = categories[row.category] + if (!policy?.public) { + await app.authenticate(request, reply) + if (reply.sent) return + if ( + row.owner_id !== request.auth.user.id && + request.auth.user.role !== 'administrator' + ) { + return responseError(reply, 403, '没有权限读取该文件') + } + } + + const targetFile = resolveStoredFile(uploadDir, row.storage_key) + if (!targetFile) return responseError(reply, 404, '文件不存在') + + let fileStats + try { + fileStats = await stat(targetFile) + } catch (error) { + if (error?.code === 'ENOENT') + return responseError(reply, 404, '文件不存在') + throw error + } + + reply + .type(row.mime_type) + .header('Content-Length', String(fileStats.size)) + .header('Content-Disposition', contentDisposition(row.original_name)) + .header('X-Content-Type-Options', 'nosniff') + .header( + 'Cache-Control', + policy.public + ? 'public, max-age=31536000, immutable' + : 'private, no-store' + ) + return reply.send(createReadStream(targetFile)) + }) + + app.get( + '/api/v1/files/:id', + { preHandler: app.authenticate }, + async (request, reply) => { + const result = idSchema.safeParse(request.params) + if (!result.success) return validationError(reply, result) + + const row = db + .prepare('SELECT * FROM files WHERE id = ? LIMIT 1') + .get(result.data.id) + if (!row) return responseError(reply, 404, '文件不存在') + if ( + row.owner_id !== request.auth.user.id && + request.auth.user.role !== 'administrator' + ) { + return responseError(reply, 403, '没有权限查看文件信息') + } + + return responseData(reply, fileData(row)) + } + ) + + app.delete( + '/api/v1/files/:id', + { preHandler: app.authenticate }, + async (request, reply) => { + const result = idSchema.safeParse(request.params) + if (!result.success) return validationError(reply, result) + + const row = db + .prepare('SELECT * FROM files WHERE id = ? LIMIT 1') + .get(result.data.id) + if (!row) return responseError(reply, 404, '文件不存在') + if ( + row.owner_id !== request.auth.user.id && + request.auth.user.role !== 'administrator' + ) { + return responseError(reply, 403, '没有权限删除文件') + } + + const targetFile = resolveStoredFile(uploadDir, row.storage_key) + if (targetFile) await unlinkIfPresent(targetFile) + db.prepare('DELETE FROM files WHERE id = ?').run(row.id) + return responseData(reply, { id: row.id }, '文件已删除') + } + ) +} + +export default fileRoutes diff --git a/server/routes/notifications.js b/server/routes/notifications.js new file mode 100644 index 0000000..5ad24b2 --- /dev/null +++ b/server/routes/notifications.js @@ -0,0 +1,155 @@ +import { z } from 'zod' + +const idSchema = z + .object({ id: z.string().uuid('通知 ID 格式不正确') }) + .strict() + +const listSchema = z + .object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), + status: z.enum(['all', 'unread', 'read']).default('all'), + type: z.string().trim().min(1).max(80).optional() + }) + .strict() + +function responseData(reply, data, message = '操作成功') { + return reply.send({ code: 0, msg: message, data }) +} + +function responseError(reply, statusCode, message, data = null) { + return reply.code(statusCode).send({ code: statusCode, msg: message, data }) +} + +function validationError(reply, result) { + return responseError(reply, 400, '请求参数不正确', { + errors: result.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message + })) + }) +} + +function notificationData(row) { + return { + id: row.id, + type: row.type, + title: row.title, + body: row.body, + resourceType: row.resource_type ?? null, + resourceId: row.resource_id ?? null, + link: row.link ?? null, + readAt: row.read_at ?? null, + isRead: Boolean(row.read_at), + createdAt: row.created_at + } +} + +export async function notificationRoutes(app) { + const db = app.db + if (!db) throw new Error('notificationRoutes requires app.db') + + app.get( + '/api/v1/notifications', + { preHandler: app.authenticate }, + async (request, reply) => { + const result = listSchema.safeParse(request.query ?? {}) + if (!result.success) return validationError(reply, result) + + const { page, pageSize, status, type } = result.data + const conditions = ['user_id = ?'] + const parameters = [request.auth.user.id] + + if (status === 'unread') conditions.push('read_at IS NULL') + if (status === 'read') conditions.push('read_at IS NOT NULL') + if (type) { + conditions.push('type = ?') + parameters.push(type) + } + + const where = `WHERE ${conditions.join(' AND ')}` + const total = Number( + db + .prepare(`SELECT COUNT(*) AS count FROM notifications ${where}`) + .get(...parameters).count + ) + const unread = Number( + db + .prepare( + 'SELECT COUNT(*) AS count FROM notifications WHERE user_id = ? AND read_at IS NULL' + ) + .get(request.auth.user.id).count + ) + const rows = db + .prepare( + `SELECT * FROM notifications + ${where} + ORDER BY created_at DESC, id ASC + LIMIT ? OFFSET ?` + ) + .all(...parameters, pageSize, (page - 1) * pageSize) + + return responseData(reply, { + items: rows.map(notificationData), + unread, + pagination: { + page, + pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / pageSize) + } + }) + } + ) + + app.patch( + '/api/v1/notifications/:id/read', + { preHandler: app.authenticate }, + async (request, reply) => { + const result = idSchema.safeParse(request.params) + if (!result.success) return validationError(reply, result) + + const notification = db + .prepare( + 'SELECT * FROM notifications WHERE id = ? AND user_id = ? LIMIT 1' + ) + .get(result.data.id, request.auth.user.id) + if (!notification) return responseError(reply, 404, '通知不存在') + + if (!notification.read_at) { + const readAt = new Date().toISOString() + db.prepare( + `UPDATE notifications + SET read_at = ? + WHERE id = ? AND user_id = ? AND read_at IS NULL` + ).run(readAt, notification.id, request.auth.user.id) + notification.read_at = readAt + } + + return responseData(reply, notificationData(notification), '通知已读') + } + ) + + app.post( + '/api/v1/notifications/read-all', + { preHandler: app.authenticate }, + async (request, reply) => { + const readAt = new Date().toISOString() + const updated = db + .prepare( + `UPDATE notifications + SET read_at = ? + WHERE user_id = ? AND read_at IS NULL` + ) + .run(readAt, request.auth.user.id) + + return responseData( + reply, + { updated: updated.changes, unread: 0 }, + updated.changes > 0 ? '全部通知已读' : '没有未读通知' + ) + } + ) +} + +export default notificationRoutes diff --git a/server/routes/profile.js b/server/routes/profile.js new file mode 100644 index 0000000..832e53d --- /dev/null +++ b/server/routes/profile.js @@ -0,0 +1,408 @@ +import { randomUUID } from 'node:crypto' +import { URL } from 'node:url' + +import { z } from 'zod' + +import { hashPassword, verifyPassword } from '../lib/password.js' +import { + createSession, + publicUser, + revokeUserSessions, + SESSION_COOKIE_NAME +} from '../lib/session.js' + +const avatarUrlSchema = z + .string() + .trim() + .max(2048) + .refine( + (value) => value.startsWith('/') || URL.canParse(value), + '头像地址格式不正确' + ) + +const teacherProfileSchema = z + .object({ + school: z.string().trim().max(160).optional(), + title: z.string().trim().max(120).optional(), + experienceYears: z.coerce.number().int().min(0).max(80).optional(), + hourlyRateCents: z.coerce.number().int().min(0).max(10_000_000).optional(), + specialties: z.array(z.string().trim().min(1).max(80)).max(20).optional(), + certificates: z.array(z.string().trim().min(1).max(160)).max(20).optional(), + teachingStyle: z.array(z.string().trim().min(1).max(80)).max(20).optional(), + languages: z.array(z.string().trim().min(1).max(80)).max(20).optional() + }) + .strict() + .refine((value) => Object.values(value).some((item) => item !== undefined), { + message: '至少需要提供一个教师资料字段' + }) + +const profileSchema = z + .object({ + displayName: z.string().trim().min(1, '昵称不能为空').max(80).optional(), + avatarUrl: avatarUrlSchema.nullable().optional(), + country: z.string().trim().max(80).nullable().optional(), + region: z.string().trim().max(120).nullable().optional(), + age: z.coerce.number().int().min(6).max(120).nullable().optional(), + chineseLevel: z + .enum(['beginner', 'HSK1', 'HSK2', 'HSK3', 'HSK4', 'HSK5', 'HSK6']) + .nullable() + .optional(), + bio: z.string().trim().max(500).optional(), + teacherProfile: teacherProfileSchema.optional() + }) + .refine((value) => Object.values(value).some((item) => item !== undefined), { + message: '至少需要提供一个可修改字段' + }) + +const passwordSchema = z + .object({ + currentPassword: z.string().min(1, '请输入当前密码').max(128), + newPassword: z + .string() + .min(8, '新密码至少需要 8 个字符') + .max(128, '新密码不能超过 128 个字符') + .regex(/[A-Za-z]/, '新密码至少需要包含一个字母') + .regex(/\d/, '新密码至少需要包含一个数字'), + confirmPassword: z.string().max(128).optional() + }) + .refine( + (value) => + !value.confirmPassword || value.confirmPassword === value.newPassword, + { + path: ['confirmPassword'], + message: '两次输入的新密码不一致' + } + ) + +const profileColumnMap = { + displayName: 'display_name', + avatarUrl: 'avatar_url', + country: 'country', + region: 'region', + age: 'age', + chineseLevel: 'chinese_level', + bio: 'bio' +} + +const teacherProfileColumnMap = { + school: 'school', + title: 'title', + experienceYears: 'experience_years', + hourlyRateCents: 'hourly_rate_cents', + specialties: 'specialties_json', + certificates: 'certificates_json', + teachingStyle: 'teaching_style_json', + languages: 'languages_json' +} + +const teacherProfileJsonFields = new Set([ + 'specialties', + 'certificates', + 'teachingStyle', + 'languages' +]) + +function parseJsonArray(value) { + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +function teacherProfileData(row) { + if (!row) return null + return { + school: row.school, + title: row.title, + experienceYears: Number(row.experience_years), + rating: Number(row.rating), + hourlyRateCents: Number(row.hourly_rate_cents), + specialties: parseJsonArray(row.specialties_json), + certificates: parseJsonArray(row.certificates_json), + teachingStyle: parseJsonArray(row.teaching_style_json), + languages: parseJsonArray(row.languages_json), + verifiedAt: row.verified_at ?? null + } +} + +function profileData(db, user) { + const profile = publicUser(user) + if (profile.role !== 'teacher') return profile + const teacherProfile = db + .prepare('SELECT * FROM teacher_profiles WHERE user_id = ? LIMIT 1') + .get(profile.id) + return { ...profile, teacherProfile: teacherProfileData(teacherProfile) } +} + +function responseData(reply, data, message = '操作成功') { + return reply.send({ + code: 0, + msg: message, + data + }) +} + +function responseError(reply, statusCode, message, data = null) { + return reply.code(statusCode).send({ + code: statusCode, + msg: message, + data + }) +} + +function validationError(reply, result) { + return responseError(reply, 400, '请求参数不正确', { + errors: result.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message + })) + }) +} + +function unwrapBody(body) { + if (body && typeof body === 'object' && body.data) { + return body.data + } + + return body ?? {} +} + +function sessionMetadata(request) { + return { + userAgent: request.headers['user-agent'] ?? null, + ipAddress: request.ip ?? null + } +} + +export async function profileRoutes(app) { + const db = app.db + if (!db) { + throw new Error('profileRoutes requires app.db') + } + + app.get( + '/api/v1/me', + { preHandler: app.authenticate }, + async (request, reply) => { + const user = db + .prepare('SELECT * FROM users WHERE id = ? LIMIT 1') + .get(request.auth.user.id) + return responseData(reply, profileData(db, user)) + } + ) + + app.patch( + '/api/v1/me', + { preHandler: app.authenticate }, + async (request, reply) => { + const rawBody = unwrapBody(request.body) + const result = profileSchema.safeParse({ + ...rawBody, + displayName: rawBody.displayName ?? rawBody.name, + country: rawBody.country ?? rawBody.nationality, + chineseLevel: rawBody.chineseLevel ?? rawBody.level + }) + + if (!result.success) { + return validationError(reply, result) + } + + if (result.data.teacherProfile && request.auth.user.role !== 'teacher') { + return responseError(reply, 403, '只有教师账号可以修改教师资料') + } + + const assignments = [] + const values = [] + + for (const [field, column] of Object.entries(profileColumnMap)) { + if (result.data[field] !== undefined) { + assignments.push(`${column} = ?`) + values.push(result.data[field]) + } + } + + const updatedAt = new Date().toISOString() + assignments.push('updated_at = ?') + values.push(updatedAt, request.auth.user.id) + let verificationReset = false + + const updateProfile = db.transaction(() => { + const verifiedProfile = + request.auth.user.role === 'teacher' + ? db + .prepare( + `SELECT verified_at + FROM teacher_profiles + WHERE user_id = ? AND verified_at IS NOT NULL + LIMIT 1` + ) + .get(request.auth.user.id) + : null + + db.prepare( + `UPDATE users SET ${assignments.join(', ')} WHERE id = ?` + ).run(...values) + + if (result.data.teacherProfile) { + db.prepare( + `INSERT INTO teacher_profiles (user_id, created_at, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(user_id) DO NOTHING` + ).run(request.auth.user.id, updatedAt, updatedAt) + + const teacherAssignments = [] + const teacherValues = [] + for (const [field, column] of Object.entries( + teacherProfileColumnMap + )) { + if (result.data.teacherProfile[field] !== undefined) { + teacherAssignments.push(`${column} = ?`) + teacherValues.push( + teacherProfileJsonFields.has(field) + ? JSON.stringify(result.data.teacherProfile[field]) + : result.data.teacherProfile[field] + ) + } + } + teacherAssignments.push('updated_at = ?') + teacherValues.push(updatedAt, request.auth.user.id) + db.prepare( + `UPDATE teacher_profiles + SET ${teacherAssignments.join(', ')} WHERE user_id = ?` + ).run(...teacherValues) + } + + if (verifiedProfile) { + const reset = db + .prepare( + `UPDATE teacher_profiles + SET verified_at = NULL, updated_at = ? + WHERE user_id = ? AND verified_at IS NOT NULL` + ) + .run(updatedAt, request.auth.user.id) + verificationReset = reset.changes === 1 + + if (verificationReset) { + db.prepare( + `INSERT INTO audit_logs ( + id, actor_id, action, entity_type, entity_id, details_json, + request_id, ip_address, user_agent, created_at + ) VALUES (?, ?, 'teacher.verification.reset', 'teacher', ?, ?, ?, ?, ?, ?)` + ).run( + randomUUID(), + request.auth.user.id, + request.auth.user.id, + JSON.stringify({ reason: 'profile_updated' }), + request.id ?? null, + request.ip ?? null, + request.headers['user-agent'] ?? null, + updatedAt + ) + } + } + }) + updateProfile() + + const user = db + .prepare('SELECT * FROM users WHERE id = ? LIMIT 1') + .get(request.auth.user.id) + + return responseData( + reply, + profileData(db, user), + verificationReset + ? '个人资料已更新,教师认证需要重新审核' + : '个人资料已更新' + ) + } + ) + + app.patch( + '/api/v1/me/password', + { + preHandler: app.authenticate, + config: { + rateLimit: { + max: 5, + timeWindow: '1 minute' + } + } + }, + async (request, reply) => { + const rawBody = unwrapBody(request.body) + const result = passwordSchema.safeParse({ + ...rawBody, + currentPassword: rawBody.currentPassword ?? rawBody.oldPassword + }) + + if (!result.success) { + return validationError(reply, result) + } + + const user = db + .prepare('SELECT * FROM users WHERE id = ? LIMIT 1') + .get(request.auth.user.id) + + if ( + !user || + !(await verifyPassword(result.data.currentPassword, user.password_hash)) + ) { + return responseError(reply, 400, '当前密码不正确') + } + + if (await verifyPassword(result.data.newPassword, user.password_hash)) { + return responseError(reply, 400, '新密码不能与当前密码相同') + } + + const passwordHash = await hashPassword(result.data.newPassword) + const updatedAt = new Date().toISOString() + let replacementSession + + const rotatePasswordAndSessions = db.transaction(() => { + const updateResult = db + .prepare( + `UPDATE users + SET password_hash = ?, updated_at = ? + WHERE id = ? AND password_hash = ?` + ) + .run(passwordHash, updatedAt, user.id, user.password_hash) + + if (updateResult.changes !== 1) { + throw new Error('PASSWORD_CHANGED_CONCURRENTLY') + } + + revokeUserSessions(db, user.id) + return createSession(db, user.id, { + ttlSeconds: app.sessionTtlSeconds, + ...sessionMetadata(request) + }) + }) + + try { + replacementSession = rotatePasswordAndSessions() + } catch (error) { + if (error?.message === 'PASSWORD_CHANGED_CONCURRENTLY') { + return responseError(reply, 409, '密码已发生变化,请重新登录后再试') + } + + throw error + } + + reply.setCookie( + SESSION_COOKIE_NAME, + replacementSession.token, + app.sessionCookieOptions(replacementSession.ttlSeconds) + ) + + const updatedUser = db + .prepare('SELECT * FROM users WHERE id = ? LIMIT 1') + .get(user.id) + + return responseData(reply, profileData(db, updatedUser), '密码已更新') + } + ) +} + +export default profileRoutes diff --git a/server/routes/realtime.js b/server/routes/realtime.js new file mode 100644 index 0000000..a8f1287 --- /dev/null +++ b/server/routes/realtime.js @@ -0,0 +1,904 @@ +import { Buffer } from 'node:buffer' +import { createHash, randomBytes, randomUUID } from 'node:crypto' + +import { z } from 'zod' + +const TICKET_TTL_MS = 60_000 +const TICKET_SWEEP_INTERVAL_MS = 30_000 +const CONNECTION_SWEEP_INTERVAL_MS = 10_000 +const CLASSROOM_JOIN_EARLY_MS = 30 * 60 * 1000 +const CLASSROOM_JOIN_GRACE_MS = 2 * 60 * 60 * 1000 +const MAX_EVENT_BYTES = 64 * 1024 +const MAX_CHAT_LENGTH = 4000 +const MAX_CONNECTIONS_PER_USER = 3 +const EVENT_RATE_WINDOW_MS = 10_000 +const MAX_EVENTS_PER_WINDOW = 120 + +const classroomParamsSchema = z + .object({ id: z.string().uuid('课堂 ID 格式不正确') }) + .strict() + +const historyQuerySchema = z + .object({ + limit: z.coerce.number().int().min(1).max(100).default(50), + before: z + .string() + .trim() + .refine((value) => Number.isFinite(Date.parse(value)), '时间格式不正确') + .optional() + }) + .strict() + +const ticketQuerySchema = z + .object({ + ticket: z.string().trim().min(32).max(256) + }) + .strict() + +const chatEventSchema = z + .object({ + type: z.literal('chat.message'), + clientMessageId: z.string().trim().min(1).max(128), + content: z.string().trim().min(1).max(MAX_CHAT_LENGTH) + }) + .strict() + +const handRaiseEventSchema = z + .object({ + type: z.literal('hand.raise'), + raised: z.boolean().default(true) + }) + .strict() + +const rtcDescriptionSchema = z + .object({ + type: z.enum(['offer', 'answer']), + sdp: z.string().min(1).max(50_000) + }) + .strict() + +const rtcCandidateSchema = z + .object({ + candidate: z.string().max(50_000), + sdpMid: z.string().max(256).nullable().optional(), + sdpMLineIndex: z.number().int().min(0).max(65_535).nullable().optional(), + usernameFragment: z.string().max(256).nullable().optional() + }) + .strict() + +const rtcOfferEventSchema = z + .object({ + type: z.literal('rtc.offer'), + targetUserId: z.string().uuid(), + description: rtcDescriptionSchema + }) + .strict() + +const rtcAnswerEventSchema = z + .object({ + type: z.literal('rtc.answer'), + targetUserId: z.string().uuid(), + description: rtcDescriptionSchema + }) + .strict() + +const rtcCandidateEventSchema = z + .object({ + type: z.literal('rtc.candidate'), + targetUserId: z.string().uuid(), + candidate: rtcCandidateSchema.nullable() + }) + .strict() + +const pingEventSchema = z + .object({ + type: z.literal('ping'), + nonce: z.string().max(128).optional() + }) + .strict() + +function responseData(reply, data, message = '操作成功', statusCode = 200) { + return reply.code(statusCode).send({ code: 0, msg: message, data }) +} + +function responseError(reply, statusCode, message, data = null) { + return reply.code(statusCode).send({ + code: statusCode, + msg: message, + data + }) +} + +function validationError(reply, result) { + return responseError(reply, 400, '请求参数不正确', { + errors: result.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message + })) + }) +} + +function ticketHash(ticket) { + return createHash('sha256').update(ticket).digest('hex') +} + +function parseJsonObject(value) { + try { + const parsed = JSON.parse(value ?? '{}') + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed + : {} + } catch { + return {} + } +} + +function userData(row, prefix = '') { + return { + id: row[`${prefix}id`], + displayName: row[`${prefix}display_name`], + avatarUrl: row[`${prefix}avatar_url`] ?? null, + role: row[`${prefix}role`] + } +} + +function messageData(row) { + return { + id: row.id, + classroomId: row.classroom_id, + senderId: row.sender_id ?? null, + sender: row.sender_id + ? { + id: row.sender_id, + displayName: row.sender_display_name, + avatarUrl: row.sender_avatar_url ?? null, + role: row.sender_role + } + : null, + messageType: row.message_type, + content: row.content, + metadata: parseJsonObject(row.metadata_json), + clientMessageId: row.client_message_id ?? null, + createdAt: row.created_at, + editedAt: row.edited_at ?? null, + deletedAt: row.deleted_at ?? null + } +} + +const messageSelect = ` + SELECT + message.*, + sender.display_name AS sender_display_name, + sender.avatar_url AS sender_avatar_url, + sender.role AS sender_role + FROM chat_messages AS message + LEFT JOIN users AS sender ON sender.id = message.sender_id +` + +function findClassroom(db, classroomId) { + return db + .prepare( + `SELECT + classroom.id, + classroom.room_code, + classroom.status, + appointment.status AS appointment_status, + appointment.scheduled_start, + appointment.scheduled_end, + appointment.student_id, + appointment.teacher_id, + student.display_name AS student_display_name, + student.avatar_url AS student_avatar_url, + student.role AS student_role, + student.status AS student_status, + teacher.display_name AS teacher_display_name, + teacher.avatar_url AS teacher_avatar_url, + teacher.role AS teacher_role, + teacher.status AS teacher_status + FROM classrooms AS classroom + INNER JOIN appointments AS appointment + ON appointment.id = classroom.appointment_id + INNER JOIN users AS student ON student.id = appointment.student_id + INNER JOIN users AS teacher ON teacher.id = appointment.teacher_id + WHERE classroom.id = ? + LIMIT 1` + ) + .get(classroomId) +} + +function participantFor(classroom, userId) { + if (classroom.student_id === userId) { + return userData(classroom, 'student_') + } + if (classroom.teacher_id === userId) { + return userData(classroom, 'teacher_') + } + return null +} + +function isActiveParticipant(classroom, userId) { + if (classroom.student_id === userId) { + return classroom.student_status === 'active' + } + if (classroom.teacher_id === userId) { + return classroom.teacher_status === 'active' + } + return false +} + +function classroomJoinWindow(classroom, now = Date.now()) { + const scheduledStart = Date.parse(classroom.scheduled_start) + const scheduledEnd = Date.parse(classroom.scheduled_end) + const opensAt = scheduledStart - CLASSROOM_JOIN_EARLY_MS + const closesAt = scheduledEnd + CLASSROOM_JOIN_GRACE_MS + + if (!Number.isFinite(opensAt) || !Number.isFinite(closesAt)) { + return { allowed: false, reason: 'INVALID_SCHEDULE' } + } + + const window = { + opensAt: new Date(opensAt).toISOString(), + closesAt: new Date(closesAt).toISOString() + } + if (now < opensAt) return { allowed: false, reason: 'TOO_EARLY', ...window } + if (now > closesAt) return { allowed: false, reason: 'TOO_LATE', ...window } + return { allowed: true, ...window } +} + +function isDevelopmentDemoClassroom(app, classroom) { + return ( + app.config?.nodeEnv === 'development' && + classroom.room_code?.startsWith('demo-') + ) +} + +function safeSend(socket, event) { + if (socket.readyState !== 1) return false + + try { + socket.send(JSON.stringify(event)) + return true + } catch { + return false + } +} + +function protocolError(socket, code, message, data = null) { + safeSend(socket, { + type: 'error', + code, + message, + data, + sentAt: new Date().toISOString() + }) +} + +function uniqueTicket(ticketStore) { + for (;;) { + const ticket = randomBytes(32).toString('base64url') + const hash = ticketHash(ticket) + if (!ticketStore.has(hash)) return { ticket, hash } + } +} + +export async function realtimeRoutes(app) { + const db = app.db + if (!db) throw new Error('realtimeRoutes requires app.db') + + const tickets = new Map() + const rooms = new Map() + const connectionClaims = new WeakMap() + const ticketClaim = Symbol('realtimeTicketClaim') + + function removeExpiredTickets(now = Date.now()) { + for (const [hash, ticket] of tickets) { + if (ticket.expiresAt <= now) tickets.delete(hash) + } + } + + function consumeTicket(rawTicket) { + if (typeof rawTicket !== 'string') return null + + const hash = ticketHash(rawTicket) + const ticket = tickets.get(hash) + tickets.delete(hash) + + if (!ticket || ticket.expiresAt <= Date.now()) return null + return ticket + } + + function roomUsers(classroomId) { + let users = rooms.get(classroomId) + if (!users) { + users = new Map() + rooms.set(classroomId, users) + } + return users + } + + function addSocket(claim, socket) { + const users = roomUsers(claim.classroomId) + let sockets = users.get(claim.user.id) + const firstForUser = !sockets + if (!sockets) { + sockets = new Set() + users.set(claim.user.id, sockets) + } + sockets.add(socket) + connectionClaims.set(socket, claim) + return firstForUser + } + + function removeSocket(claim, socket) { + const users = rooms.get(claim.classroomId) + const sockets = users?.get(claim.user.id) + if (!sockets) return false + + sockets.delete(socket) + connectionClaims.delete(socket) + if (sockets.size > 0) return false + + users.delete(claim.user.id) + if (users.size === 0) rooms.delete(claim.classroomId) + return true + } + + function broadcast(classroomId, event) { + const users = rooms.get(classroomId) + if (!users) return + + for (const sockets of users.values()) { + for (const socket of sockets) safeSend(socket, event) + } + } + + function sendToUser(classroomId, userId, event) { + const sockets = rooms.get(classroomId)?.get(userId) + if (!sockets) return false + + let sent = false + for (const socket of sockets) sent = safeSend(socket, event) || sent + return sent + } + + function onlineParticipants(classroomId) { + return [...(rooms.get(classroomId)?.keys() ?? [])] + } + + function connectionCount(classroomId, userId) { + return rooms.get(classroomId)?.get(userId)?.size ?? 0 + } + + function claimCanAccessClassroom(claim) { + const now = new Date().toISOString() + const session = db + .prepare( + `SELECT session.id + FROM sessions AS session + INNER JOIN users AS user ON user.id = session.user_id + WHERE session.id = ? + AND session.user_id = ? + AND session.revoked_at IS NULL + AND session.expires_at > ? + AND user.status = 'active' + LIMIT 1` + ) + .get(claim.sessionId, claim.user.id, now) + if (!session) return false + + const classroom = findClassroom(db, claim.classroomId) + if ( + !classroom || + classroom.appointment_status !== 'accepted' || + classroom.status === 'closed' || + !isActiveParticipant(classroom, claim.user.id) + ) { + return false + } + + return ( + classroomJoinWindow(classroom).allowed || + isDevelopmentDemoClassroom(app, classroom) + ) + } + + function closeStaleConnections() { + for (const users of rooms.values()) { + for (const sockets of users.values()) { + for (const socket of sockets) { + const claim = connectionClaims.get(socket) + if (!claim || !claimCanAccessClassroom(claim)) { + protocolError( + socket, + 'CLASSROOM_ACCESS_REVOKED', + '课堂访问权限已失效' + ) + socket.close(1008, 'Classroom access revoked') + } + } + } + } + } + + function loadMessage(messageId) { + const row = db + .prepare(`${messageSelect} WHERE message.id = ? LIMIT 1`) + .get(messageId) + return row ? messageData(row) : null + } + + function existingClientMessage(senderId, clientMessageId) { + const row = db + .prepare( + `${messageSelect} + WHERE message.sender_id = ? AND message.client_message_id = ? + LIMIT 1` + ) + .get(senderId, clientMessageId) + return row ? messageData(row) : null + } + + function persistChatMessage(claim, event) { + const existing = existingClientMessage(claim.user.id, event.clientMessageId) + if (existing) { + if (existing.classroomId !== claim.classroomId) { + return { error: 'CLIENT_MESSAGE_ID_CONFLICT' } + } + return { message: existing, duplicate: true } + } + + const id = randomUUID() + const createdAt = new Date().toISOString() + + try { + db.prepare( + `INSERT INTO chat_messages ( + id, classroom_id, sender_id, message_type, content, + metadata_json, client_message_id, created_at + ) VALUES (?, ?, ?, 'text', ?, '{}', ?, ?)` + ).run( + id, + claim.classroomId, + claim.user.id, + event.content, + event.clientMessageId, + createdAt + ) + } catch (error) { + const isUniqueConflict = + error?.code === 'SQLITE_CONSTRAINT_UNIQUE' || + /UNIQUE constraint failed/i.test(error?.message ?? '') + if (!isUniqueConflict) throw error + + const duplicate = existingClientMessage( + claim.user.id, + event.clientMessageId + ) + if (!duplicate || duplicate.classroomId !== claim.classroomId) { + return { error: 'CLIENT_MESSAGE_ID_CONFLICT' } + } + return { message: duplicate, duplicate: true } + } + + return { message: loadMessage(id), duplicate: false } + } + + function forwardRtc(socket, claim, event) { + if ( + event.targetUserId === claim.user.id || + !claim.participantIds.includes(event.targetUserId) + ) { + return protocolError( + socket, + 'INVALID_RTC_TARGET', + '只能向同一课堂的另一位参与者发送 RTC 信令' + ) + } + + const forwarded = sendToUser(claim.classroomId, event.targetUserId, { + ...event, + classroomId: claim.classroomId, + fromUserId: claim.user.id, + sentAt: new Date().toISOString() + }) + + if (!forwarded) { + protocolError(socket, 'RTC_TARGET_OFFLINE', '目标参与者当前不在线') + } + } + + function handleEvent(socket, claim, rawData, isBinary) { + if (isBinary) { + socket.close(1003, 'Binary events are not supported') + return + } + + const size = Buffer.byteLength(rawData) + if (size > MAX_EVENT_BYTES) { + socket.close(1009, 'Event exceeds size limit') + return + } + + let event + try { + event = JSON.parse(rawData.toString('utf8')) + } catch { + protocolError(socket, 'INVALID_JSON', '消息必须是有效的 JSON') + return + } + + if (!event || typeof event !== 'object' || Array.isArray(event)) { + protocolError(socket, 'INVALID_EVENT', '消息事件格式不正确') + return + } + + if (event.type === 'chat.message') { + const result = chatEventSchema.safeParse(event) + if (!result.success) { + protocolError(socket, 'INVALID_CHAT_MESSAGE', '聊天消息格式不正确') + return + } + + try { + const outcome = persistChatMessage(claim, result.data) + if (outcome.error) { + protocolError( + socket, + outcome.error, + 'clientMessageId 已在其他课堂中使用' + ) + return + } + + const outgoing = { + type: 'chat.message', + classroomId: claim.classroomId, + message: outcome.message, + duplicate: outcome.duplicate, + sentAt: new Date().toISOString() + } + + if (outcome.duplicate) safeSend(socket, outgoing) + else broadcast(claim.classroomId, outgoing) + } catch (error) { + app.log.error({ err: error }, 'Failed to persist classroom message') + protocolError(socket, 'MESSAGE_PERSISTENCE_FAILED', '消息发送失败') + } + return + } + + if (event.type === 'hand.raise') { + const result = handRaiseEventSchema.safeParse(event) + if (!result.success) { + protocolError(socket, 'INVALID_HAND_RAISE', '举手事件格式不正确') + return + } + + broadcast(claim.classroomId, { + type: 'hand.raise', + classroomId: claim.classroomId, + user: claim.user, + raised: result.data.raised, + sentAt: new Date().toISOString() + }) + return + } + + if (event.type === 'rtc.offer') { + const result = rtcOfferEventSchema.safeParse(event) + if (!result.success) { + protocolError(socket, 'INVALID_RTC_OFFER', 'RTC offer 格式不正确') + return + } + if (result.data.description.type !== 'offer') { + protocolError(socket, 'INVALID_RTC_OFFER', 'RTC offer 类型不正确') + return + } + forwardRtc(socket, claim, result.data) + return + } + + if (event.type === 'rtc.answer') { + const result = rtcAnswerEventSchema.safeParse(event) + if (!result.success) { + protocolError(socket, 'INVALID_RTC_ANSWER', 'RTC answer 格式不正确') + return + } + if (result.data.description.type !== 'answer') { + protocolError(socket, 'INVALID_RTC_ANSWER', 'RTC answer 类型不正确') + return + } + forwardRtc(socket, claim, result.data) + return + } + + if (event.type === 'rtc.candidate') { + const result = rtcCandidateEventSchema.safeParse(event) + if (!result.success) { + protocolError( + socket, + 'INVALID_RTC_CANDIDATE', + 'RTC candidate 格式不正确' + ) + return + } + forwardRtc(socket, claim, result.data) + return + } + + if (event.type === 'ping') { + const result = pingEventSchema.safeParse(event) + if (!result.success) { + protocolError(socket, 'INVALID_PING', 'ping 事件格式不正确') + return + } + + safeSend(socket, { + type: 'pong', + nonce: result.data.nonce ?? null, + sentAt: new Date().toISOString() + }) + return + } + + protocolError(socket, 'UNKNOWN_EVENT', '不支持的课堂事件类型') + } + + const ticketSweepTimer = setInterval( + removeExpiredTickets, + TICKET_SWEEP_INTERVAL_MS + ) + const connectionSweepTimer = setInterval( + closeStaleConnections, + CONNECTION_SWEEP_INTERVAL_MS + ) + ticketSweepTimer.unref() + connectionSweepTimer.unref() + + app.addHook('onClose', async () => { + clearInterval(ticketSweepTimer) + clearInterval(connectionSweepTimer) + tickets.clear() + rooms.clear() + }) + + app.post( + '/api/v1/classrooms/:id/ticket', + { preHandler: app.authenticate }, + async (request, reply) => { + const params = classroomParamsSchema.safeParse(request.params) + if (!params.success) return validationError(reply, params) + + const classroom = findClassroom(db, params.data.id) + if (!classroom) return responseError(reply, 404, '课堂不存在') + + const participant = participantFor(classroom, request.auth.user.id) + if (!participant) { + return responseError(reply, 403, '只有课堂参与者可以获取连接票据') + } + if (!isActiveParticipant(classroom, request.auth.user.id)) { + return responseError(reply, 403, '当前账号无法加入课堂') + } + if ( + classroom.appointment_status !== 'accepted' || + classroom.status === 'closed' + ) { + return responseError(reply, 409, '课堂当前不可加入') + } + + const accessWindow = classroomJoinWindow(classroom) + if ( + !accessWindow.allowed && + !isDevelopmentDemoClassroom(app, classroom) + ) { + return responseError(reply, 409, '当前不在课堂可加入时间内', { + reason: accessWindow.reason, + opensAt: accessWindow.opensAt ?? null, + closesAt: accessWindow.closesAt ?? null + }) + } + + removeExpiredTickets() + const { ticket, hash } = uniqueTicket(tickets) + const expiresAt = Date.now() + TICKET_TTL_MS + tickets.set(hash, { + classroomId: classroom.id, + sessionId: request.auth.sessionId, + user: participant, + participantIds: [classroom.student_id, classroom.teacher_id], + expiresAt + }) + + return responseData( + reply, + { + classroomId: classroom.id, + ticket, + expiresAt: new Date(expiresAt).toISOString(), + accessWindow: { + opensAt: accessWindow.opensAt ?? null, + closesAt: accessWindow.closesAt ?? null, + demoBypass: + !accessWindow.allowed && + isDevelopmentDemoClassroom(app, classroom) + }, + websocketPath: `/ws/classroom?ticket=${encodeURIComponent(ticket)}` + }, + '连接票据已创建', + 201 + ) + } + ) + + app.get( + '/api/v1/classrooms/:id/messages', + { preHandler: app.authenticate }, + async (request, reply) => { + const params = classroomParamsSchema.safeParse(request.params) + if (!params.success) return validationError(reply, params) + + const query = historyQuerySchema.safeParse(request.query ?? {}) + if (!query.success) return validationError(reply, query) + + const classroom = findClassroom(db, params.data.id) + if (!classroom) return responseError(reply, 404, '课堂不存在') + if (!participantFor(classroom, request.auth.user.id)) { + return responseError(reply, 403, '只有课堂参与者可以查看课堂消息') + } + + const conditions = [ + 'message.classroom_id = ?', + 'message.deleted_at IS NULL' + ] + const parameters = [classroom.id] + if (query.data.before) { + conditions.push('message.created_at < ?') + parameters.push(new Date(query.data.before).toISOString()) + } + + const rows = db + .prepare( + `${messageSelect} + WHERE ${conditions.join(' AND ')} + ORDER BY message.created_at DESC, message.id DESC + LIMIT ?` + ) + .all(...parameters, query.data.limit + 1) + + const hasMore = rows.length > query.data.limit + const items = rows.slice(0, query.data.limit).reverse().map(messageData) + + return responseData(reply, { + items, + hasMore, + nextBefore: hasMore && items.length > 0 ? items[0].createdAt : null + }) + } + ) + + app.get( + '/ws/classroom', + { + websocket: true, + preValidation: async (request, reply) => { + const query = ticketQuerySchema.safeParse(request.query ?? {}) + if (!query.success) { + return responseError(reply, 401, '课堂连接票据无效') + } + + const claim = consumeTicket(query.data.ticket) + if (!claim) return responseError(reply, 401, '课堂连接票据无效或已过期') + + const classroom = findClassroom(db, claim.classroomId) + const accessWindow = classroom + ? classroomJoinWindow(classroom) + : { allowed: false } + if ( + !classroom || + classroom.appointment_status !== 'accepted' || + classroom.status === 'closed' || + (!accessWindow.allowed && + !isDevelopmentDemoClassroom(app, classroom)) || + !isActiveParticipant(classroom, claim.user.id) || + !claimCanAccessClassroom(claim) + ) { + return responseError(reply, 403, '当前无法加入课堂') + } + + claim.user = participantFor(classroom, claim.user.id) + claim.participantIds = [classroom.student_id, classroom.teacher_id] + request[ticketClaim] = claim + } + }, + (socket, request) => { + const claim = request[ticketClaim] + let cleanedUp = false + let eventWindowStartedAt = Date.now() + let eventCount = 0 + + if ( + connectionCount(claim.classroomId, claim.user.id) >= + MAX_CONNECTIONS_PER_USER + ) { + protocolError(socket, 'CONNECTION_LIMIT', '课堂连接数量已达到上限') + socket.close(1008, 'Connection limit reached') + return + } + + socket.on('message', (data, isBinary) => { + const now = Date.now() + if (now - eventWindowStartedAt >= EVENT_RATE_WINDOW_MS) { + eventWindowStartedAt = now + eventCount = 0 + } + eventCount += 1 + if (eventCount > MAX_EVENTS_PER_WINDOW) { + protocolError(socket, 'EVENT_RATE_LIMIT', '课堂事件发送过于频繁') + socket.close(1008, 'Event rate limit exceeded') + return + } + if (!claimCanAccessClassroom(claim)) { + protocolError( + socket, + 'CLASSROOM_ACCESS_REVOKED', + '课堂访问权限已失效' + ) + socket.close(1008, 'Classroom access revoked') + return + } + handleEvent(socket, claim, data, isBinary) + }) + + const cleanup = () => { + if (cleanedUp) return + cleanedUp = true + + if (removeSocket(claim, socket)) { + broadcast(claim.classroomId, { + type: 'presence.left', + classroomId: claim.classroomId, + user: claim.user, + onlineUserIds: onlineParticipants(claim.classroomId), + sentAt: new Date().toISOString() + }) + } + } + + socket.once('close', cleanup) + socket.once('error', cleanup) + + const firstForUser = addSocket(claim, socket) + db.prepare( + `UPDATE classrooms + SET status = 'open', + opened_at = COALESCE(opened_at, ?), + updated_at = ? + WHERE id = ? AND status = 'scheduled'` + ).run( + new Date().toISOString(), + new Date().toISOString(), + claim.classroomId + ) + + safeSend(socket, { + type: 'presence.snapshot', + classroomId: claim.classroomId, + user: claim.user, + onlineUserIds: onlineParticipants(claim.classroomId), + sentAt: new Date().toISOString() + }) + + if (firstForUser) { + broadcast(claim.classroomId, { + type: 'presence.joined', + classroomId: claim.classroomId, + user: claim.user, + onlineUserIds: onlineParticipants(claim.classroomId), + sentAt: new Date().toISOString() + }) + } + } + ) +} + +export default realtimeRoutes diff --git a/server/routes/teachers.js b/server/routes/teachers.js new file mode 100644 index 0000000..c152b1c --- /dev/null +++ b/server/routes/teachers.js @@ -0,0 +1,228 @@ +import { z } from 'zod' + +const teacherListQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(50).default(12), + search: z.string().trim().max(100).optional(), + specialty: z.string().trim().max(80).optional(), + minRating: z.coerce.number().min(0).max(5).optional() +}) + +function responseData(reply, data, message = '操作成功', statusCode = 200) { + return reply.code(statusCode).send({ + code: 0, + msg: message, + data + }) +} + +function responseError(reply, statusCode, message, data = null) { + return reply.code(statusCode).send({ + code: statusCode, + msg: message, + data + }) +} + +function validationError(reply, result) { + return responseError(reply, 400, '请求参数不正确', { + errors: result.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message + })) + }) +} + +function parseJsonArray(value) { + if (typeof value !== 'string' || value.length === 0) { + return [] + } + + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +function teacherData(row) { + return { + id: row.id, + displayName: row.display_name, + avatarUrl: row.avatar_url ?? null, + country: row.country ?? null, + region: row.region ?? null, + bio: row.bio ?? '', + school: row.school ?? '', + title: row.title ?? '', + experienceYears: Number(row.experience_years ?? 0), + rating: Number(row.rating ?? 5), + hourlyRateCents: Number(row.hourly_rate_cents ?? 0), + specialties: parseJsonArray(row.specialties_json), + certificates: parseJsonArray(row.certificates_json), + teachingStyle: parseJsonArray(row.teaching_style_json), + languages: parseJsonArray(row.languages_json), + verified: Boolean(row.verified_at), + verifiedAt: row.verified_at ?? null, + publishedCourseCount: Number(row.published_course_count ?? 0) + } +} + +function courseData(row) { + return { + id: row.id, + title: row.title, + summary: row.summary, + description: row.description, + level: row.level, + category: row.category, + coverUrl: row.cover_url ?? null, + durationMinutes: row.duration_minutes, + priceCents: row.price_cents, + capacity: row.capacity, + publishedAt: row.published_at, + createdAt: row.created_at, + updatedAt: row.updated_at + } +} + +const teacherSelect = ` + SELECT + u.id, + u.display_name, + u.avatar_url, + u.country, + u.region, + u.bio, + tp.school, + tp.title, + tp.experience_years, + tp.rating, + tp.hourly_rate_cents, + tp.specialties_json, + tp.certificates_json, + tp.teaching_style_json, + tp.languages_json, + tp.verified_at, + COUNT(c.id) AS published_course_count + FROM users AS u + LEFT JOIN teacher_profiles AS tp ON tp.user_id = u.id + LEFT JOIN courses AS c + ON c.teacher_id = u.id + AND c.status = 'published' +` + +export async function teacherRoutes(app) { + const db = app.db + if (!db) { + throw new Error('teacherRoutes requires app.db') + } + + app.get('/api/v1/teachers', async (request, reply) => { + const result = teacherListQuerySchema.safeParse(request.query ?? {}) + if (!result.success) { + return validationError(reply, result) + } + + const { page, pageSize, search, specialty, minRating } = result.data + const conditions = [ + "u.role = 'teacher'", + "u.status = 'active'", + 'tp.verified_at IS NOT NULL' + ] + const parameters = [] + + if (search) { + const pattern = `%${search}%` + conditions.push(`( + u.display_name LIKE ? + OR COALESCE(u.bio, '') LIKE ? + OR COALESCE(tp.school, '') LIKE ? + OR COALESCE(tp.title, '') LIKE ? + )`) + parameters.push(pattern, pattern, pattern, pattern) + } + + if (specialty) { + conditions.push("COALESCE(tp.specialties_json, '[]') LIKE ?") + parameters.push(`%${specialty}%`) + } + + if (minRating !== undefined) { + conditions.push('COALESCE(tp.rating, 5) >= ?') + parameters.push(minRating) + } + + const where = `WHERE ${conditions.join(' AND ')}` + const total = db + .prepare( + `SELECT COUNT(*) AS count + FROM users AS u + LEFT JOIN teacher_profiles AS tp ON tp.user_id = u.id + ${where}` + ) + .get(...parameters).count + const offset = (page - 1) * pageSize + const rows = db + .prepare( + `${teacherSelect} + ${where} + GROUP BY u.id + ORDER BY + COALESCE(tp.rating, 5) DESC, + COALESCE(tp.experience_years, 0) DESC, + u.display_name COLLATE NOCASE ASC, + u.id ASC + LIMIT ? OFFSET ?` + ) + .all(...parameters, pageSize, offset) + + return responseData(reply, { + items: rows.map(teacherData), + pagination: { + page, + pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / pageSize) + } + }) + }) + + app.get('/api/v1/teachers/:id', async (request, reply) => { + const teacher = db + .prepare( + `${teacherSelect} + WHERE u.id = ? + AND u.role = 'teacher' + AND u.status = 'active' + AND tp.verified_at IS NOT NULL + GROUP BY u.id + LIMIT 1` + ) + .get(request.params.id) + + if (!teacher) { + return responseError(reply, 404, '教师不存在') + } + + const courses = db + .prepare( + `SELECT + id, title, summary, description, level, category, cover_url, + duration_minutes, price_cents, capacity, published_at, + created_at, updated_at + FROM courses + WHERE teacher_id = ? AND status = 'published' + ORDER BY published_at DESC, created_at DESC, id ASC` + ) + .all(teacher.id) + + return responseData(reply, { + ...teacherData(teacher), + courses: courses.map(courseData) + }) + }) +} + +export default teacherRoutes diff --git a/server/services/dialogue-provider.js b/server/services/dialogue-provider.js new file mode 100644 index 0000000..9bcad56 --- /dev/null +++ b/server/services/dialogue-provider.js @@ -0,0 +1,165 @@ +import { z } from 'zod' + +const generatedSchema = z + .object({ + title: z.string().trim().min(1).max(160).optional(), + turns: z + .array( + z.object({ + speaker: z.enum(['student', 'tutor', 'system']), + content: z.string().trim().min(1).max(4000) + }) + ) + .min(2) + .max(20) + }) + .strict() + +const replySchema = z + .object({ reply: z.string().trim().min(1).max(4000) }) + .strict() + +const MAX_PROVIDER_RESPONSE_BYTES = 128 * 1024 + +function localTitle(keywords) { + return `${keywords.slice(0, 3).join(' · ')} 对话练习` +} + +function localDialogue(keywords) { + const topic = keywords.join('、') + const focus = keywords[0] + return { + title: localTitle(keywords), + turns: [ + { + speaker: 'system', + content: `本次练习围绕“${topic}”展开,请尽量使用完整中文句子。` + }, + { + speaker: 'tutor', + content: `你好!今天我们来练习“${topic}”。你能先用“${focus}”说一句话吗?` + } + ] + } +} + +function localReply({ keywords, message }) { + const focus = keywords.find((keyword) => message.includes(keyword)) + if (focus) { + return `很好,你已经正确使用了“${focus}”。可以再补充时间、地点或原因,让句子更完整吗?` + } + + return `我明白了。试着把“${keywords[0]}”放进刚才的句子里,再说一遍吧。` +} + +async function requestExternal({ apiUrl, apiKey, payload, schema }) { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}) + }, + body: JSON.stringify(payload), + redirect: 'error', + signal: AbortSignal.timeout(10_000) + }) + + if (!response.ok) { + throw new Error(`AI provider returned HTTP ${response.status}`) + } + + const declaredLength = Number(response.headers.get('content-length')) + if ( + Number.isFinite(declaredLength) && + declaredLength > MAX_PROVIDER_RESPONSE_BYTES + ) { + throw new Error('AI provider response exceeds the size limit') + } + + const responseText = await response.text() + if ( + new TextEncoder().encode(responseText).byteLength > + MAX_PROVIDER_RESPONSE_BYTES + ) { + throw new Error('AI provider response exceeds the size limit') + } + + return schema.parse(JSON.parse(responseText)) +} + +export function createDialogueProvider({ + apiUrl = '', + apiKey = '', + logger +} = {}) { + const externalEnabled = Boolean(apiUrl) + + return Object.freeze({ + async generate({ keywords, title }) { + if (externalEnabled) { + try { + const generated = await requestExternal({ + apiUrl, + apiKey, + payload: { + task: 'generate-dialogue', + language: 'zh-CN', + keywords, + title: title || undefined + }, + schema: generatedSchema + }) + return { + provider: 'external', + title: title || generated.title || localTitle(keywords), + turns: generated.turns + } + } catch (error) { + logger?.warn?.( + { err: error }, + 'AI provider unavailable; using local dialogue' + ) + } + } + + const generated = localDialogue(keywords) + return { + provider: 'local', + title: title || generated.title, + turns: generated.turns + } + }, + + async reply({ keywords, history, message }) { + if (externalEnabled) { + try { + const generated = await requestExternal({ + apiUrl, + apiKey, + payload: { + task: 'continue-dialogue', + language: 'zh-CN', + keywords, + history, + message + }, + schema: replySchema + }) + return { provider: 'external', content: generated.reply } + } catch (error) { + logger?.warn?.( + { err: error }, + 'AI provider unavailable; using local reply' + ) + } + } + + return { + provider: 'local', + content: localReply({ keywords, message }) + } + } + }) +} + +export default createDialogueProvider diff --git a/server/services/mail-provider.js b/server/services/mail-provider.js new file mode 100644 index 0000000..bccae6b --- /dev/null +++ b/server/services/mail-provider.js @@ -0,0 +1,47 @@ +import nodemailer from 'nodemailer' + +export function createMailProvider({ + smtpUrl, + mailFrom, + transportFactory = nodemailer.createTransport +} = {}) { + if (!smtpUrl) return null + + const transporter = transportFactory({ + url: smtpUrl, + connectionTimeout: 10_000, + greetingTimeout: 10_000, + socketTimeout: 15_000, + requireTLS: true, + disableFileAccess: true, + disableUrlAccess: true + }) + + return Object.freeze({ + async sendVerificationCode({ email, code, expiresAt }) { + const expiry = new Intl.DateTimeFormat('zh-CN', { + dateStyle: 'medium', + timeStyle: 'short', + timeZone: 'Asia/Shanghai' + }).format(new Date(expiresAt)) + + return transporter.sendMail({ + from: mailFrom, + to: email, + subject: '国际中文学习平台注册验证码', + text: `你的注册验证码是 ${code},有效期至 ${expiry}。请勿向任何人透露此验证码。`, + html: ` +
+

INTERNATIONAL CHINESE PLATFORM

+

完成你的平台注册

+

请在注册页面输入下面的六位验证码:

+

${code}

+

验证码有效期至 ${expiry}。如果这不是你的操作,请忽略本邮件。

+
+ ` + }) + } + }) +} + +export default createMailProvider diff --git a/server/test/appointment-workflow.test.js b/server/test/appointment-workflow.test.js new file mode 100644 index 0000000..2abc6a9 --- /dev/null +++ b/server/test/appointment-workflow.test.js @@ -0,0 +1,680 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import test from 'node:test' + +import cookie from '@fastify/cookie' +import Fastify from 'fastify' + +import { createDatabase } from '../db/database.js' +import { createSession } from '../lib/session.js' +import authPlugin from '../plugins/auth.js' +import appointmentRoutes from '../routes/appointments.js' +import teacherRoutes from '../routes/teachers.js' + +function readBody(response) { + return JSON.parse(response.body) +} + +function bearer(token) { + return { authorization: `Bearer ${token}` } +} + +function addUser(database, { id = randomUUID(), role, displayName }) { + database + .prepare( + `INSERT INTO users ( + id, email, password_hash, role, display_name, status + ) VALUES (?, ?, ?, ?, ?, 'active')` + ) + .run(id, `${role}-${id}@example.com`, 'x'.repeat(64), role, displayName) + + const session = createSession(database, id, { ttlSeconds: 3600 }) + return { id, role, displayName, token: session.token } +} + +function addTeacherProfile(database, teacherId, overrides = {}) { + database + .prepare( + `INSERT INTO teacher_profiles ( + user_id, school, title, experience_years, rating, + hourly_rate_cents, specialties_json, certificates_json, + teaching_style_json, languages_json, verified_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + teacherId, + overrides.school ?? '国际中文学院', + overrides.title ?? '国际中文讲师', + overrides.experienceYears ?? 6, + overrides.rating ?? 4.8, + overrides.hourlyRateCents ?? 16800, + JSON.stringify(overrides.specialties ?? ['口语', 'HSK']), + JSON.stringify(overrides.certificates ?? ['国际中文教师证书']), + JSON.stringify(overrides.teachingStyle ?? ['情景教学']), + JSON.stringify(overrides.languages ?? ['中文', '英语']), + overrides.verifiedAt ?? '2026-01-01T00:00:00.000Z' + ) +} + +function addCourse(database, teacherId, overrides = {}) { + const id = overrides.id ?? randomUUID() + database + .prepare( + `INSERT INTO courses ( + id, teacher_id, title, summary, level, category, + duration_minutes, price_cents, status, published_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + id, + teacherId, + overrides.title ?? '生活汉语', + overrides.summary ?? '真实场景中文训练', + overrides.level ?? 'beginner', + overrides.category ?? '综合汉语', + overrides.durationMinutes ?? 45, + overrides.priceCents ?? 9900, + overrides.status ?? 'published', + overrides.status === 'draft' ? null : '2026-01-01T00:00:00.000Z' + ) + return id +} + +function futureIso(minutesFromNow) { + return new Date(Date.now() + minutesFromNow * 60_000).toISOString() +} + +async function createTestApp(t) { + const database = createDatabase({ filename: ':memory:' }) + const app = Fastify({ logger: false }) + app.decorate('db', database) + + await app.register(cookie) + await authPlugin(app, { + db: database, + secureCookies: false, + sessionTtlSeconds: 3600 + }) + await app.register(teacherRoutes) + await app.register(appointmentRoutes) + await app.ready() + + t.after(async () => { + await app.close() + database.close() + }) + + return { app, database } +} + +async function requestAppointment( + app, + student, + teacher, + courseId, + overrides = {} +) { + return app.inject({ + method: 'POST', + url: '/api/v1/appointments', + headers: bearer(student.token), + payload: { + teacherId: teacher.id, + courseId, + topic: overrides.topic ?? '口语练习', + message: overrides.message ?? '希望加强真实情景表达。', + scheduledStart: overrides.scheduledStart ?? futureIso(180), + durationMinutes: overrides.durationMinutes ?? 45 + } + }) +} + +test('teacher discovery exposes active teachers and only their published courses', async (t) => { + const { app, database } = await createTestApp(t) + const teacher = addUser(database, { + role: 'teacher', + displayName: '陈老师' + }) + addTeacherProfile(database, teacher.id, { specialties: ['商务汉语'] }) + const publishedCourseId = addCourse(database, teacher.id) + addCourse(database, teacher.id, { status: 'draft', title: '未发布课程' }) + + const listing = await app.inject({ + method: 'GET', + url: '/api/v1/teachers?search=%E9%99%88&specialty=%E5%95%86%E5%8A%A1' + }) + + assert.equal(listing.statusCode, 200) + assert.equal(readBody(listing).code, 0) + assert.equal(readBody(listing).data.items.length, 1) + assert.equal(readBody(listing).data.items[0].publishedCourseCount, 1) + assert.equal(readBody(listing).data.items[0].rating, 4.8) + + const detail = await app.inject({ + method: 'GET', + url: `/api/v1/teachers/${teacher.id}` + }) + + assert.equal(detail.statusCode, 200) + assert.deepEqual( + readBody(detail).data.courses.map((course) => course.id), + [publishedCourseId] + ) +}) + +test('a student request can be accepted atomically with a classroom, notifications and audit', async (t) => { + const { app, database } = await createTestApp(t) + const student = addUser(database, { + role: 'student', + displayName: '学习者甲' + }) + const teacher = addUser(database, { + role: 'teacher', + displayName: '教师甲' + }) + addTeacherProfile(database, teacher.id) + const courseId = addCourse(database, teacher.id) + + const created = await requestAppointment(app, student, teacher, courseId, { + scheduledStart: futureIso(10) + }) + assert.equal(created.statusCode, 201) + const appointmentId = readBody(created).data.id + assert.equal(readBody(created).data.status, 'pending') + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM notifications + WHERE resource_id = ? AND type = 'appointment.requested'` + ) + .get(appointmentId).count, + 1 + ) + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs + WHERE action = 'appointment.requested' AND entity_id = ?` + ) + .get(appointmentId).count, + 1 + ) + + database + .prepare('UPDATE teacher_profiles SET verified_at = NULL WHERE user_id = ?') + .run(teacher.id) + const acceptWhileUnverified = await app.inject({ + method: 'PATCH', + url: `/api/v1/appointments/${appointmentId}/respond`, + headers: bearer(teacher.token), + payload: { action: 'accept' } + }) + assert.equal(acceptWhileUnverified.statusCode, 409) + assert.equal( + database + .prepare('SELECT status FROM appointments WHERE id = ?') + .get(appointmentId).status, + 'pending' + ) + assert.equal( + database + .prepare( + 'SELECT COUNT(*) AS count FROM classrooms WHERE appointment_id = ?' + ) + .get(appointmentId).count, + 0 + ) + database + .prepare('UPDATE teacher_profiles SET verified_at = ? WHERE user_id = ?') + .run(new Date().toISOString(), teacher.id) + + const accepted = await app.inject({ + method: 'PATCH', + url: `/api/v1/appointments/${appointmentId}/respond`, + headers: bearer(teacher.token), + payload: { action: 'accept', note: '课堂见。' } + }) + + assert.equal(accepted.statusCode, 200) + const acceptedData = readBody(accepted).data + assert.equal(acceptedData.status, 'accepted') + assert.equal(acceptedData.responseNote, '课堂见。') + assert.match(acceptedData.classroom.roomCode, /^[A-Za-z0-9_-]{6,64}$/) + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM notifications + WHERE resource_type = 'appointment' AND resource_id = ?` + ) + .get(appointmentId).count, + 3 + ) + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs + WHERE action = 'appointment.accepted' AND entity_id = ?` + ) + .get(appointmentId).count, + 1 + ) + + const joinInfo = await app.inject({ + method: 'GET', + url: `/api/v1/classrooms/${acceptedData.classroom.id}/join-info`, + headers: bearer(student.token) + }) + assert.equal(joinInfo.statusCode, 200) + assert.equal( + readBody(joinInfo).data.roomCode, + acceptedData.classroom.roomCode + ) + assert.deepEqual( + new Set(readBody(joinInfo).data.participants.map((item) => item.role)), + new Set(['student', 'teacher']) + ) + assert.deepEqual(readBody(joinInfo).data.iceServers, []) +}) + +test('teachers can reject pending requests and either participant can cancel an active request', async (t) => { + const { app, database } = await createTestApp(t) + const student = addUser(database, { + role: 'student', + displayName: '学习者乙' + }) + const teacher = addUser(database, { + role: 'teacher', + displayName: '教师乙' + }) + addTeacherProfile(database, teacher.id) + const courseId = addCourse(database, teacher.id) + + const rejectedRequest = await requestAppointment( + app, + student, + teacher, + courseId, + { scheduledStart: futureIso(300) } + ) + const rejectedId = readBody(rejectedRequest).data.id + const rejected = await app.inject({ + method: 'PATCH', + url: `/api/v1/appointments/${rejectedId}/respond`, + headers: bearer(teacher.token), + payload: { action: 'reject', note: '该时段无法授课。' } + }) + assert.equal(rejected.statusCode, 200) + assert.equal(readBody(rejected).data.status, 'rejected') + assert.equal( + database + .prepare( + 'SELECT COUNT(*) AS count FROM classrooms WHERE appointment_id = ?' + ) + .get(rejectedId).count, + 0 + ) + + const pendingRequest = await requestAppointment( + app, + student, + teacher, + courseId, + { scheduledStart: futureIso(420) } + ) + const pendingId = readBody(pendingRequest).data.id + const studentCancelled = await app.inject({ + method: 'PATCH', + url: `/api/v1/appointments/${pendingId}/cancel`, + headers: bearer(student.token), + payload: { reason: '行程有变' } + }) + assert.equal(studentCancelled.statusCode, 200) + assert.equal(readBody(studentCancelled).data.status, 'cancelled') + + const acceptedRequest = await requestAppointment( + app, + student, + teacher, + courseId, + { scheduledStart: futureIso(540) } + ) + const acceptedId = readBody(acceptedRequest).data.id + await app.inject({ + method: 'PATCH', + url: `/api/v1/appointments/${acceptedId}/respond`, + headers: bearer(teacher.token), + payload: { action: 'accept' } + }) + const teacherCancelled = await app.inject({ + method: 'PATCH', + url: `/api/v1/appointments/${acceptedId}/cancel`, + headers: bearer(teacher.token), + payload: { reason: '临时教研安排' } + }) + assert.equal(teacherCancelled.statusCode, 200) + assert.equal(readBody(teacherCancelled).data.status, 'cancelled') + assert.equal(readBody(teacherCancelled).data.classroom.status, 'closed') +}) + +test('accepting an overlapping lesson is rejected without partial side effects', async (t) => { + const { app, database } = await createTestApp(t) + const student = addUser(database, { + role: 'student', + displayName: '学习者丙' + }) + const teacher = addUser(database, { + role: 'teacher', + displayName: '教师丙' + }) + addTeacherProfile(database, teacher.id) + const courseId = addCourse(database, teacher.id) + const sharedStart = futureIso(720) + + const first = await requestAppointment(app, student, teacher, courseId, { + scheduledStart: sharedStart, + durationMinutes: 60, + topic: '第一节课' + }) + const second = await requestAppointment(app, student, teacher, courseId, { + scheduledStart: new Date( + new Date(sharedStart).getTime() + 30 * 60_000 + ).toISOString(), + durationMinutes: 60, + topic: '冲突课程' + }) + const firstId = readBody(first).data.id + const secondId = readBody(second).data.id + + const accepted = await app.inject({ + method: 'PATCH', + url: `/api/v1/appointments/${firstId}/respond`, + headers: bearer(teacher.token), + payload: { action: 'accept' } + }) + assert.equal(accepted.statusCode, 200) + + const conflicted = await app.inject({ + method: 'PATCH', + url: `/api/v1/appointments/${secondId}/respond`, + headers: bearer(teacher.token), + payload: { action: 'accept' } + }) + assert.equal(conflicted.statusCode, 409) + assert.equal(readBody(conflicted).data.conflictingAppointmentId, firstId) + assert.equal( + database + .prepare('SELECT status FROM appointments WHERE id = ?') + .get(secondId).status, + 'pending' + ) + assert.equal( + database + .prepare( + 'SELECT COUNT(*) AS count FROM classrooms WHERE appointment_id = ?' + ) + .get(secondId).count, + 0 + ) +}) + +test('a student cannot be accepted into overlapping lessons with different teachers', async (t) => { + const { app, database } = await createTestApp(t) + const student = addUser(database, { + role: 'student', + displayName: '档期冲突学生' + }) + const firstTeacher = addUser(database, { + role: 'teacher', + displayName: '第一位教师' + }) + const secondTeacher = addUser(database, { + role: 'teacher', + displayName: '第二位教师' + }) + addTeacherProfile(database, firstTeacher.id) + addTeacherProfile(database, secondTeacher.id) + const firstCourseId = addCourse(database, firstTeacher.id) + const secondCourseId = addCourse(database, secondTeacher.id) + const sharedStart = futureIso(780) + + const first = await requestAppointment( + app, + student, + firstTeacher, + firstCourseId, + { + scheduledStart: sharedStart, + durationMinutes: 60, + topic: '第一位教师的课堂' + } + ) + const second = await requestAppointment( + app, + student, + secondTeacher, + secondCourseId, + { + scheduledStart: new Date( + new Date(sharedStart).getTime() + 20 * 60_000 + ).toISOString(), + durationMinutes: 60, + topic: '第二位教师的重叠课堂' + } + ) + const firstId = readBody(first).data.id + const secondId = readBody(second).data.id + + const accepted = await app.inject({ + method: 'PATCH', + url: `/api/v1/appointments/${firstId}/respond`, + headers: bearer(firstTeacher.token), + payload: { action: 'accept' } + }) + assert.equal(accepted.statusCode, 200) + + const conflicted = await app.inject({ + method: 'PATCH', + url: `/api/v1/appointments/${secondId}/respond`, + headers: bearer(secondTeacher.token), + payload: { action: 'accept' } + }) + assert.equal(conflicted.statusCode, 409) + assert.equal(readBody(conflicted).data.conflictingAppointmentId, firstId) + assert.equal(readBody(conflicted).data.conflictingParticipant, 'student') + assert.equal( + database + .prepare('SELECT status FROM appointments WHERE id = ?') + .get(secondId).status, + 'pending' + ) + assert.equal( + database + .prepare( + 'SELECT COUNT(*) AS count FROM classrooms WHERE appointment_id = ?' + ) + .get(secondId).count, + 0 + ) + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs + WHERE entity_id = ? AND action = 'appointment.accepted'` + ) + .get(secondId).count, + 0 + ) +}) + +test('a participant completes a classroom atomically while outsiders are rejected', async (t) => { + const { app, database } = await createTestApp(t) + const student = addUser(database, { + role: 'student', + displayName: '结课学生' + }) + const outsider = addUser(database, { + role: 'student', + displayName: '无关结课用户' + }) + const teacher = addUser(database, { + role: 'teacher', + displayName: '结课教师' + }) + addTeacherProfile(database, teacher.id) + const courseId = addCourse(database, teacher.id) + const created = await requestAppointment(app, student, teacher, courseId) + const appointmentId = readBody(created).data.id + const accepted = await app.inject({ + method: 'PATCH', + url: `/api/v1/appointments/${appointmentId}/respond`, + headers: bearer(teacher.token), + payload: { action: 'accept' } + }) + const classroomId = readBody(accepted).data.classroom.id + + const forbidden = await app.inject({ + method: 'POST', + url: `/api/v1/classrooms/${classroomId}/complete`, + headers: bearer(outsider.token) + }) + assert.equal(forbidden.statusCode, 403) + + const beforeOpening = await app.inject({ + method: 'POST', + url: `/api/v1/classrooms/${classroomId}/complete`, + headers: bearer(student.token) + }) + assert.equal(beforeOpening.statusCode, 409) + + const openedAt = new Date().toISOString() + database + .prepare( + `UPDATE classrooms + SET status = 'open', opened_at = ?, updated_at = ? + WHERE id = ?` + ) + .run(openedAt, openedAt, classroomId) + + const completed = await app.inject({ + method: 'POST', + url: `/api/v1/classrooms/${classroomId}/complete`, + headers: bearer(student.token) + }) + assert.equal(completed.statusCode, 200) + assert.equal(readBody(completed).data.status, 'completed') + assert.equal(readBody(completed).data.classroom.status, 'closed') + + const persisted = database + .prepare( + `SELECT + a.status AS appointment_status, + classroom.status AS classroom_status, + classroom.opened_at, + classroom.closed_at + FROM appointments AS a + INNER JOIN classrooms AS classroom ON classroom.appointment_id = a.id + WHERE a.id = ?` + ) + .get(appointmentId) + assert.equal(persisted.appointment_status, 'completed') + assert.equal(persisted.classroom_status, 'closed') + assert.ok(Date.parse(persisted.opened_at) <= Date.parse(persisted.closed_at)) + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM notifications + WHERE resource_id = ? AND user_id = ? AND type = 'appointment.completed'` + ) + .get(appointmentId, teacher.id).count, + 1 + ) + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs + WHERE entity_id = ? AND action = 'appointment.completed'` + ) + .get(appointmentId).count, + 1 + ) + + const repeated = await app.inject({ + method: 'POST', + url: `/api/v1/classrooms/${classroomId}/complete`, + headers: bearer(teacher.token) + }) + assert.equal(repeated.statusCode, 409) + assert.equal(readBody(repeated).data.appointmentStatus, 'completed') +}) + +test('appointment ownership prevents response, listing and classroom access leaks', async (t) => { + const { app, database } = await createTestApp(t) + const student = addUser(database, { + role: 'student', + displayName: '学习者丁' + }) + const outsiderStudent = addUser(database, { + role: 'student', + displayName: '无关学生' + }) + const teacher = addUser(database, { + role: 'teacher', + displayName: '教师丁' + }) + const outsiderTeacher = addUser(database, { + role: 'teacher', + displayName: '无关教师' + }) + addTeacherProfile(database, teacher.id) + addTeacherProfile(database, outsiderTeacher.id) + const courseId = addCourse(database, teacher.id) + const created = await requestAppointment(app, student, teacher, courseId) + const appointmentId = readBody(created).data.id + + const forbiddenResponse = await app.inject({ + method: 'PATCH', + url: `/api/v1/appointments/${appointmentId}/respond`, + headers: bearer(outsiderTeacher.token), + payload: { action: 'accept' } + }) + assert.equal(forbiddenResponse.statusCode, 403) + + const outsiderList = await app.inject({ + method: 'GET', + url: '/api/v1/appointments', + headers: bearer(outsiderStudent.token) + }) + assert.equal(outsiderList.statusCode, 200) + assert.equal(readBody(outsiderList).data.items.length, 0) + + const accepted = await app.inject({ + method: 'PATCH', + url: `/api/v1/appointments/${appointmentId}/respond`, + headers: bearer(teacher.token), + payload: { action: 'accept' } + }) + const classroomId = readBody(accepted).data.classroom.id + const forbiddenJoin = await app.inject({ + method: 'GET', + url: `/api/v1/classrooms/${classroomId}/join-info`, + headers: bearer(outsiderStudent.token) + }) + assert.equal(forbiddenJoin.statusCode, 403) + + const tooEarlyJoin = await app.inject({ + method: 'GET', + url: `/api/v1/classrooms/${classroomId}/join-info`, + headers: bearer(student.token) + }) + assert.equal(tooEarlyJoin.statusCode, 409) + assert.equal(readBody(tooEarlyJoin).data.reason, 'TOO_EARLY') + + const invalidRequest = await requestAppointment( + app, + student, + teacher, + courseId, + { scheduledStart: new Date(Date.now() - 60_000).toISOString() } + ) + assert.equal(invalidRequest.statusCode, 400) +}) diff --git a/server/test/assignment-workflow.test.js b/server/test/assignment-workflow.test.js new file mode 100644 index 0000000..74a97bf --- /dev/null +++ b/server/test/assignment-workflow.test.js @@ -0,0 +1,686 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import test from 'node:test' + +import cookie from '@fastify/cookie' +import Fastify from 'fastify' + +import { createDatabase } from '../db/database.js' +import { createSession } from '../lib/session.js' +import authPlugin from '../plugins/auth.js' +import assignmentRoutes from '../routes/assignments.js' + +function bearer(token) { + return { authorization: `Bearer ${token}` } +} + +function body(response) { + return JSON.parse(response.body) +} + +function addUser(database, role, displayName) { + const id = randomUUID() + database + .prepare( + `INSERT INTO users ( + id, email, password_hash, role, display_name, status + ) VALUES (?, ?, ?, ?, ?, 'active')` + ) + .run(id, `${role}-${id}@assignment.test`, 'x'.repeat(64), role, displayName) + const session = createSession(database, id, { ttlSeconds: 3600 }) + + return { id, role, headers: bearer(session.token) } +} + +function addCourse(database, teacherId, status = 'published') { + const id = randomUUID() + database + .prepare( + `INSERT INTO courses ( + id, teacher_id, title, summary, description, level, category, + duration_minutes, price_cents, capacity, status, published_at + ) VALUES (?, ?, ?, '', '', 'beginner', '综合汉语', 60, 0, 30, ?, ?)` + ) + .run( + id, + teacherId, + `作业测试课程-${id}`, + status, + status === 'published' ? new Date().toISOString() : null + ) + return id +} + +async function createAssignmentTestApp(t) { + const database = createDatabase({ filename: ':memory:' }) + const app = Fastify({ logger: false }) + app.decorate('db', database) + + await app.register(cookie) + await authPlugin(app, { + db: database, + allowBearer: true, + secureCookies: false, + sessionTtlSeconds: 3600 + }) + await app.register(assignmentRoutes) + await app.ready() + + t.after(async () => { + await app.close() + database.close() + }) + + return { app, database } +} + +function assignmentPayload(title = '第一单元作业') { + return { + title, + instructions: '完成选择题和简答题。', + dueAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + maxScore: 100, + questions: [ + { + type: 'single_choice', + prompt: '见面时最自然的问候是哪一句?', + options: ['你好', '再见', '谢谢'], + correctAnswer: '你好', + points: 40, + explanation: '见面时可以说“你好”。' + }, + { + type: 'text', + prompt: '请写两句话介绍自己。', + correctAnswer: '绝密参考答案:我叫小林,我喜欢学习中文。', + points: 60, + explanation: '绝密解析:回答应包含姓名和兴趣。' + } + ] + } +} + +test('student submission, teacher grading and student result form a complete workflow', async (t) => { + const { app, database } = await createAssignmentTestApp(t) + const student = addUser(database, 'student', '测试学生') + const teacher = addUser(database, 'teacher', '王老师') + const otherTeacher = addUser(database, 'teacher', '李老师') + const courseId = addCourse(database, teacher.id) + + const created = await app.inject({ + method: 'POST', + url: `/api/v1/courses/${courseId}/assignments`, + headers: teacher.headers, + payload: assignmentPayload() + }) + assert.equal(created.statusCode, 201) + assert.equal(body(created).code, 0) + assert.equal(body(created).data.status, 'draft') + const assignmentId = body(created).data.id + + const hiddenBeforePublish = await app.inject({ + method: 'GET', + url: `/api/v1/assignments/${assignmentId}`, + headers: student.headers + }) + assert.equal(hiddenBeforePublish.statusCode, 404) + + const forbiddenPublish = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${assignmentId}/publish`, + headers: otherTeacher.headers + }) + assert.equal(forbiddenPublish.statusCode, 403) + + const published = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${assignmentId}/publish`, + headers: teacher.headers + }) + assert.equal(published.statusCode, 200) + assert.equal(body(published).data.status, 'published') + + const studentDetail = await app.inject({ + method: 'GET', + url: `/api/v1/assignments/${assignmentId}`, + headers: student.headers + }) + assert.equal(studentDetail.statusCode, 200) + const questions = body(studentDetail).data.questions + assert.equal(questions.length, 2) + assert.equal('correctAnswer' in questions[0], false) + assert.equal('correctAnswer' in questions[1], false) + assert.equal('explanation' in questions[0], false) + assert.doesNotMatch(studentDetail.body, /绝密参考答案|绝密解析/) + + const [choiceQuestion, textQuestion] = questions + const savedDraft = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${assignmentId}/submissions`, + headers: student.headers, + payload: { + action: 'save', + answers: { [choiceQuestion.id]: '你好' } + } + }) + assert.equal(savedDraft.statusCode, 201) + assert.equal(body(savedDraft).data.status, 'draft') + const submissionId = body(savedDraft).data.id + + const hiddenStudentDraft = await app.inject({ + method: 'GET', + url: `/api/v1/assignments/${assignmentId}/submissions`, + headers: teacher.headers + }) + assert.equal(hiddenStudentDraft.statusCode, 200) + assert.equal(body(hiddenStudentDraft).data.items.length, 0) + + const incompleteSubmit = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${assignmentId}/submissions`, + headers: student.headers, + payload: { + answers: { [choiceQuestion.id]: '你好' } + } + }) + assert.equal(incompleteSubmit.statusCode, 400) + + const submitted = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${assignmentId}/submissions`, + headers: student.headers, + payload: { + answers: { + [choiceQuestion.id]: '你好', + [textQuestion.id]: '我叫林安,我喜欢读中文故事。' + } + } + }) + assert.equal(submitted.statusCode, 200) + assert.equal(body(submitted).data.id, submissionId) + assert.equal(body(submitted).data.status, 'submitted') + + const duplicateSubmit = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${assignmentId}/submissions`, + headers: student.headers, + payload: { + answers: { + [choiceQuestion.id]: '你好', + [textQuestion.id]: '重复提交。' + } + } + }) + assert.equal(duplicateSubmit.statusCode, 409) + + const forbiddenSubmissions = await app.inject({ + method: 'GET', + url: `/api/v1/assignments/${assignmentId}/submissions`, + headers: otherTeacher.headers + }) + assert.equal(forbiddenSubmissions.statusCode, 403) + + const teacherSubmissions = await app.inject({ + method: 'GET', + url: `/api/v1/assignments/${assignmentId}/submissions?status=submitted`, + headers: teacher.headers + }) + assert.equal(teacherSubmissions.statusCode, 200) + assert.equal(body(teacherSubmissions).data.items.length, 1) + assert.equal(body(teacherSubmissions).data.items[0].student.id, student.id) + + const forbiddenGrade = await app.inject({ + method: 'PATCH', + url: `/api/v1/submissions/${submissionId}/grade`, + headers: otherTeacher.headers, + payload: { score: 90, feedback: '越权批阅' } + }) + assert.equal(forbiddenGrade.statusCode, 403) + + const invalidScore = await app.inject({ + method: 'PATCH', + url: `/api/v1/submissions/${submissionId}/grade`, + headers: teacher.headers, + payload: { score: 101, feedback: '超过满分' } + }) + assert.equal(invalidScore.statusCode, 400) + + const graded = await app.inject({ + method: 'PATCH', + url: `/api/v1/submissions/${submissionId}/grade`, + headers: teacher.headers, + payload: { score: 92, feedback: '表达完整,继续注意量词。' } + }) + assert.equal(graded.statusCode, 200) + assert.equal(body(graded).data.status, 'graded') + assert.equal(body(graded).data.score, 92) + + const duplicateGrade = await app.inject({ + method: 'PATCH', + url: `/api/v1/submissions/${submissionId}/grade`, + headers: teacher.headers, + payload: { score: 95, feedback: '重复批阅' } + }) + assert.equal(duplicateGrade.statusCode, 409) + + const studentResults = await app.inject({ + method: 'GET', + url: '/api/v1/me/submissions?status=graded', + headers: student.headers + }) + assert.equal(studentResults.statusCode, 200) + assert.equal(body(studentResults).data.items.length, 1) + assert.equal(body(studentResults).data.items[0].score, 92) + assert.equal( + body(studentResults).data.items[0].feedback, + '表达完整,继续注意量词。' + ) + + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM notifications + WHERE user_id = ? AND type = 'submission.graded' AND resource_id = ?` + ) + .get(student.id, submissionId).count, + 1 + ) + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs + WHERE action = 'submission.graded' AND entity_id = ?` + ) + .get(submissionId).count, + 1 + ) +}) + +test('assignment visibility, ownership and state transitions stay explicit', async (t) => { + const { app, database } = await createAssignmentTestApp(t) + const student = addUser(database, 'student', '学生乙') + const teacher = addUser(database, 'teacher', '教师乙') + const otherTeacher = addUser(database, 'teacher', '教师丙') + const draftCourseId = addCourse(database, teacher.id, 'draft') + + const invalidPoints = await app.inject({ + method: 'POST', + url: `/api/v1/courses/${draftCourseId}/assignments`, + headers: teacher.headers, + payload: { + ...assignmentPayload('分值错误作业'), + maxScore: 99 + } + }) + assert.equal(invalidPoints.statusCode, 400) + + const forbiddenCreate = await app.inject({ + method: 'POST', + url: `/api/v1/courses/${draftCourseId}/assignments`, + headers: otherTeacher.headers, + payload: assignmentPayload('越权作业') + }) + assert.equal(forbiddenCreate.statusCode, 403) + + const created = await app.inject({ + method: 'POST', + url: `/api/v1/courses/${draftCourseId}/assignments`, + headers: teacher.headers, + payload: assignmentPayload('课程尚未发布的作业') + }) + assert.equal(created.statusCode, 201) + const assignmentId = body(created).data.id + + const updated = await app.inject({ + method: 'PATCH', + url: `/api/v1/assignments/${assignmentId}`, + headers: teacher.headers, + payload: { instructions: '更新后的作业说明。' } + }) + assert.equal(updated.statusCode, 200) + + const published = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${assignmentId}/publish`, + headers: teacher.headers + }) + assert.equal(published.statusCode, 200) + + const secondPublish = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${assignmentId}/publish`, + headers: teacher.headers + }) + assert.equal(secondPublish.statusCode, 409) + + const updateAfterPublish = await app.inject({ + method: 'PATCH', + url: `/api/v1/assignments/${assignmentId}`, + headers: teacher.headers, + payload: { instructions: '发布后不应允许修改。' } + }) + assert.equal(updateAfterPublish.statusCode, 409) + + const studentCourseList = await app.inject({ + method: 'GET', + url: `/api/v1/courses/${draftCourseId}/assignments`, + headers: student.headers + }) + assert.equal(studentCourseList.statusCode, 404) + + const studentDetail = await app.inject({ + method: 'GET', + url: `/api/v1/assignments/${assignmentId}`, + headers: student.headers + }) + assert.equal(studentDetail.statusCode, 404) + + const studentSubmit = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${assignmentId}/submissions`, + headers: student.headers, + payload: { answers: {} } + }) + assert.equal(studentSubmit.statusCode, 409) +}) + +test('expired deadlines reject publishing and submission without partial writes', async (t) => { + const { app, database } = await createAssignmentTestApp(t) + const student = addUser(database, 'student', '截止时间学生') + const secondStudent = addUser(database, 'student', '逾期新学生') + const teacher = addUser(database, 'teacher', '截止时间教师') + const courseId = addCourse(database, teacher.id) + + const expiredDraft = await app.inject({ + method: 'POST', + url: `/api/v1/courses/${courseId}/assignments`, + headers: teacher.headers, + payload: { + ...assignmentPayload('已过期的草稿'), + dueAt: new Date(Date.now() - 60_000).toISOString() + } + }) + assert.equal(expiredDraft.statusCode, 201) + const expiredDraftId = body(expiredDraft).data.id + + const expiredPublish = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${expiredDraftId}/publish`, + headers: teacher.headers + }) + assert.equal(expiredPublish.statusCode, 409) + assert.match(body(expiredPublish).msg, /截止时间已过/) + assert.equal( + database + .prepare('SELECT status FROM assignments WHERE id = ?') + .get(expiredDraftId).status, + 'draft' + ) + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs + WHERE action = 'assignment.published' AND entity_id = ?` + ) + .get(expiredDraftId).count, + 0 + ) + + const activeDraft = await app.inject({ + method: 'POST', + url: `/api/v1/courses/${courseId}/assignments`, + headers: teacher.headers, + payload: assignmentPayload('会在作答期间过期的作业') + }) + assert.equal(activeDraft.statusCode, 201) + const assignmentId = body(activeDraft).data.id + const published = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${assignmentId}/publish`, + headers: teacher.headers + }) + assert.equal(published.statusCode, 200) + + const detail = await app.inject({ + method: 'GET', + url: `/api/v1/assignments/${assignmentId}`, + headers: student.headers + }) + const [choiceQuestion, textQuestion] = body(detail).data.questions + const initialAnswers = { [choiceQuestion.id]: '你好' } + const initialDraft = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${assignmentId}/submissions`, + headers: student.headers, + payload: { action: 'save', answers: initialAnswers } + }) + assert.equal(initialDraft.statusCode, 201) + const submissionId = body(initialDraft).data.id + + database + .prepare('UPDATE assignments SET due_at = ? WHERE id = ?') + .run(new Date(Date.now() - 60_000).toISOString(), assignmentId) + + const savedAfterDeadline = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${assignmentId}/submissions`, + headers: student.headers, + payload: { + action: 'save', + answers: { [choiceQuestion.id]: '再见' } + } + }) + assert.equal(savedAfterDeadline.statusCode, 200) + assert.equal(body(savedAfterDeadline).data.status, 'draft') + + const newDraftAfterDeadline = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${assignmentId}/submissions`, + headers: secondStudent.headers, + payload: { + action: 'save', + answers: { [choiceQuestion.id]: '你好' } + } + }) + assert.equal(newDraftAfterDeadline.statusCode, 409) + assert.match(body(newDraftAfterDeadline).msg, /不能新建草稿/) + assert.match(body(newDraftAfterDeadline).data.draftPolicy, /截止时间前/) + + const lateSubmit = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${assignmentId}/submissions`, + headers: student.headers, + payload: { + action: 'submit', + answers: { + [choiceQuestion.id]: '你好', + [textQuestion.id]: '这次提交不应写入数据库。' + } + } + }) + assert.equal(lateSubmit.statusCode, 409) + assert.match(body(lateSubmit).msg, /超过截止时间/) + assert.match(body(lateSubmit).data.draftPolicy, /仍可保存/) + + const storedSubmission = database + .prepare('SELECT * FROM submissions WHERE id = ?') + .get(submissionId) + assert.equal(storedSubmission.status, 'draft') + assert.deepEqual(JSON.parse(storedSubmission.answers_json), { + [choiceQuestion.id]: '再见' + }) + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM submissions + WHERE assignment_id = ? AND student_id = ?` + ) + .get(assignmentId, secondStudent.id).count, + 0 + ) + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs + WHERE action = 'submission.submitted' AND entity_id = ?` + ) + .get(submissionId).count, + 0 + ) + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM notifications + WHERE type = 'assignment.submitted' AND resource_id = ?` + ) + .get(submissionId).count, + 0 + ) +}) + +test('closing and archiving block writes while keeping assignment details readable', async (t) => { + const { app, database } = await createAssignmentTestApp(t) + const student = addUser(database, 'student', '状态流转学生') + const teacher = addUser(database, 'teacher', '状态流转教师') + const otherTeacher = addUser(database, 'teacher', '越权教师') + const courseId = addCourse(database, teacher.id) + + const closedDraft = await app.inject({ + method: 'POST', + url: `/api/v1/courses/${courseId}/assignments`, + headers: teacher.headers, + payload: assignmentPayload('待关闭作业') + }) + const closedAssignmentId = body(closedDraft).data.id + const published = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${closedAssignmentId}/publish`, + headers: teacher.headers + }) + assert.equal(published.statusCode, 200) + + const forbiddenClose = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${closedAssignmentId}/close`, + headers: otherTeacher.headers + }) + assert.equal(forbiddenClose.statusCode, 403) + assert.equal( + database + .prepare('SELECT status FROM assignments WHERE id = ?') + .get(closedAssignmentId).status, + 'published' + ) + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs + WHERE action = 'assignment.closed' AND entity_id = ?` + ) + .get(closedAssignmentId).count, + 0 + ) + + const closed = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${closedAssignmentId}/close`, + headers: teacher.headers + }) + assert.equal(closed.statusCode, 200) + assert.equal(body(closed).data.status, 'closed') + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs + WHERE action = 'assignment.closed' AND entity_id = ?` + ) + .get(closedAssignmentId).count, + 1 + ) + + const closedDetail = await app.inject({ + method: 'GET', + url: `/api/v1/assignments/${closedAssignmentId}`, + headers: student.headers + }) + assert.equal(closedDetail.statusCode, 200) + assert.equal(body(closedDetail).data.status, 'closed') + assert.equal('correctAnswer' in body(closedDetail).data.questions[0], false) + + const closedSubmit = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${closedAssignmentId}/submissions`, + headers: student.headers, + payload: { action: 'save', answers: {} } + }) + assert.equal(closedSubmit.statusCode, 409) + assert.match(body(closedSubmit).msg, /已关闭/) + assert.equal( + database + .prepare( + 'SELECT COUNT(*) AS count FROM submissions WHERE assignment_id = ?' + ) + .get(closedAssignmentId).count, + 0 + ) + + const secondClose = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${closedAssignmentId}/close`, + headers: teacher.headers + }) + assert.equal(secondClose.statusCode, 409) + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs + WHERE action = 'assignment.closed' AND entity_id = ?` + ) + .get(closedAssignmentId).count, + 1 + ) + + const archivedDraft = await app.inject({ + method: 'POST', + url: `/api/v1/courses/${courseId}/assignments`, + headers: teacher.headers, + payload: assignmentPayload('课程归档后仍可查看的作业') + }) + const archivedAssignmentId = body(archivedDraft).data.id + const archivedPublished = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${archivedAssignmentId}/publish`, + headers: teacher.headers + }) + assert.equal(archivedPublished.statusCode, 200) + database + .prepare( + "UPDATE courses SET status = 'archived', published_at = NULL WHERE id = ?" + ) + .run(courseId) + + const archivedDetail = await app.inject({ + method: 'GET', + url: `/api/v1/assignments/${archivedAssignmentId}`, + headers: student.headers + }) + assert.equal(archivedDetail.statusCode, 200) + assert.equal(body(archivedDetail).data.course.status, 'archived') + + const archivedSubmit = await app.inject({ + method: 'POST', + url: `/api/v1/assignments/${archivedAssignmentId}/submissions`, + headers: student.headers, + payload: { action: 'save', answers: {} } + }) + assert.equal(archivedSubmit.statusCode, 409) + assert.match(body(archivedSubmit).msg, /课程已归档/) + assert.equal( + database + .prepare( + 'SELECT COUNT(*) AS count FROM submissions WHERE assignment_id = ?' + ) + .get(archivedAssignmentId).count, + 0 + ) +}) diff --git a/server/test/auth.test.js b/server/test/auth.test.js new file mode 100644 index 0000000..9f11481 --- /dev/null +++ b/server/test/auth.test.js @@ -0,0 +1,394 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import cookie from '@fastify/cookie' +import Fastify from 'fastify' + +import { createDatabase } from '../db/database.js' +import { createSession } from '../lib/session.js' +import authPlugin from '../plugins/auth.js' +import authRoutes from '../routes/auth.js' +import profileRoutes from '../routes/profile.js' + +function readBody(response) { + return JSON.parse(response.body) +} + +function cookieHeader(response) { + const setCookie = response.headers['set-cookie'] + const value = Array.isArray(setCookie) ? setCookie[0] : setCookie + assert.equal(typeof value, 'string') + return value.split(';', 1)[0] +} + +function cookieToken(cookieValue) { + return decodeURIComponent(cookieValue.slice(cookieValue.indexOf('=') + 1)) +} + +async function createAuthTestApp(t) { + const db = createDatabase({ filename: ':memory:' }) + const app = Fastify({ logger: false }) + app.decorate('db', db) + + await app.register(cookie) + await authPlugin(app, { + appOrigin: 'http://localhost:5173', + db, + secureCookies: false, + sessionTtlSeconds: 3600 + }) + await app.register(authRoutes) + await app.register(profileRoutes) + + app.get( + '/test/teacher-only', + { preHandler: app.requireRole('teacher') }, + async () => ({ code: 0, msg: 'ok', data: true }) + ) + + await app.ready() + t.after(async () => { + await app.close() + db.close() + }) + + return { app, db } +} + +async function register(app, overrides = {}) { + const email = overrides.email ?? 'student@example.com' + const role = overrides.role ?? 'student' + const codeResponse = await app.inject({ + method: 'POST', + url: '/api/v1/auth/verification-code', + payload: { email, role } + }) + assert.equal(codeResponse.statusCode, 200) + const verificationCode = readBody(codeResponse).data.developmentCode + + return app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email, + password: 'Secure123!', + role, + displayName: '测试学生', + verificationCode, + ...overrides + } + }) +} + +test('register creates a cookie session without exposing its token', async (t) => { + const { app, db } = await createAuthTestApp(t) + const response = await register(app) + + assert.equal(response.statusCode, 201) + const payload = readBody(response) + assert.equal(payload.code, 0) + assert.equal(payload.data.email, 'student@example.com') + assert.equal(payload.data.role, 'student') + assert.equal(Object.hasOwn(payload.data, 'token'), false) + assert.equal(Object.hasOwn(payload.data, 'password_hash'), false) + + const rawSetCookie = response.headers['set-cookie'] + assert.match(rawSetCookie, /HttpOnly/i) + assert.match(rawSetCookie, /SameSite=Lax/i) + + const sessionCookie = cookieHeader(response) + const rawToken = cookieToken(sessionCookie) + const storedSession = db + .prepare('SELECT token_hash FROM sessions LIMIT 1') + .get() + + assert.equal(storedSession.token_hash.length, 64) + assert.notEqual(storedSession.token_hash, rawToken) + + const authenticated = await app.inject({ + method: 'GET', + url: '/api/v1/auth/session', + headers: { cookie: sessionCookie } + }) + + assert.equal(authenticated.statusCode, 200) + assert.equal(readBody(authenticated).data.id, payload.data.id) + + const anonymous = await app.inject({ + method: 'GET', + url: '/api/v1/auth/session' + }) + assert.equal(anonymous.statusCode, 401) +}) + +test('teacher registration creates an active account pending administrator verification', async (t) => { + const { app, db } = await createAuthTestApp(t) + const response = await register(app, { + email: 'new-teacher@example.com', + role: 'teacher', + displayName: '新教师', + school: '国际中文学院', + certificates: ['国际中文教师证书'] + }) + + assert.equal(response.statusCode, 201) + const teacher = db + .prepare( + `SELECT u.status, tp.verified_at + FROM users AS u + INNER JOIN teacher_profiles AS tp ON tp.user_id = u.id + WHERE u.email = ?` + ) + .get('new-teacher@example.com') + + assert.equal(teacher.status, 'active') + assert.equal(teacher.verified_at, null) +}) + +test('register rejects duplicate accounts and administrator self-registration', async (t) => { + const { app } = await createAuthTestApp(t) + const first = await register(app) + assert.equal(first.statusCode, 201) + + const duplicate = await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: 'student@example.com', + password: 'Secure123!', + role: 'student', + verificationCode: '000000' + } + }) + assert.equal(duplicate.statusCode, 409) + assert.equal(readBody(duplicate).code, 409) + + const administrator = await app.inject({ + method: 'POST', + url: '/api/v1/auth/verification-code', + payload: { + email: 'admin-signup@example.com', + role: 'administrator' + } + }) + assert.equal(administrator.statusCode, 400) +}) + +test('login validates the selected role and returns only the public profile', async (t) => { + const { app } = await createAuthTestApp(t) + await register(app) + + const wrongPassword = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { + email: 'student@example.com', + password: 'Wrong123!', + role: 'student' + } + }) + assert.equal(wrongPassword.statusCode, 401) + + const wrongRole = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { + email: 'student@example.com', + password: 'Secure123!', + role: 'teacher' + } + }) + assert.equal(wrongRole.statusCode, 401) + + const authenticated = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { + email: 'STUDENT@example.com', + password: 'Secure123!', + role: 'student' + } + }) + + assert.equal(authenticated.statusCode, 200) + const payload = readBody(authenticated) + assert.equal(payload.code, 0) + assert.equal(payload.data.role, 'student') + assert.equal(Object.hasOwn(payload.data, 'token'), false) +}) + +test('profile updates are scoped to the authenticated account', async (t) => { + const { app, db } = await createAuthTestApp(t) + const first = await register(app) + const firstCookie = cookieHeader(first) + const firstId = readBody(first).data.id + const second = await register(app, { + email: 'second@example.com', + displayName: '第二位学生' + }) + const secondId = readBody(second).data.id + + const response = await app.inject({ + method: 'PATCH', + url: '/api/v1/me', + headers: { cookie: firstCookie }, + payload: { + displayName: '更新后的昵称', + country: 'China', + age: 20, + chineseLevel: 'HSK4' + } + }) + + assert.equal(response.statusCode, 200) + assert.equal(readBody(response).data.displayName, '更新后的昵称') + assert.equal( + db.prepare('SELECT display_name FROM users WHERE id = ?').get(firstId) + .display_name, + '更新后的昵称' + ) + assert.equal( + db.prepare('SELECT display_name FROM users WHERE id = ?').get(secondId) + .display_name, + '第二位学生' + ) +}) + +test('role guard rejects students and permits teachers', async (t) => { + const { app } = await createAuthTestApp(t) + const student = await register(app) + const studentResponse = await app.inject({ + method: 'GET', + url: '/test/teacher-only', + headers: { cookie: cookieHeader(student) } + }) + assert.equal(studentResponse.statusCode, 403) + + const teacher = await register(app, { + email: 'teacher@example.com', + role: 'teacher', + displayName: '测试教师' + }) + const teacherResponse = await app.inject({ + method: 'GET', + url: '/test/teacher-only', + headers: { cookie: cookieHeader(teacher) } + }) + assert.equal(teacherResponse.statusCode, 200) +}) + +test('changing a password rotates the current session and revokes other sessions', async (t) => { + const { app } = await createAuthTestApp(t) + const registration = await register(app) + const registrationCookie = cookieHeader(registration) + + const secondLogin = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { + email: 'student@example.com', + password: 'Secure123!', + role: 'student' + } + }) + const secondCookie = cookieHeader(secondLogin) + + const changed = await app.inject({ + method: 'PATCH', + url: '/api/v1/me/password', + headers: { cookie: registrationCookie }, + payload: { + currentPassword: 'Secure123!', + newPassword: 'NewSecure456!', + confirmPassword: 'NewSecure456!' + } + }) + + assert.equal(changed.statusCode, 200) + const replacementCookie = cookieHeader(changed) + assert.notEqual(replacementCookie, registrationCookie) + + const revokedSession = await app.inject({ + method: 'GET', + url: '/api/v1/auth/session', + headers: { cookie: secondCookie } + }) + assert.equal(revokedSession.statusCode, 401) + + const replacementSession = await app.inject({ + method: 'GET', + url: '/api/v1/auth/session', + headers: { cookie: replacementCookie } + }) + assert.equal(replacementSession.statusCode, 200) + + const oldPassword = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { + email: 'student@example.com', + password: 'Secure123!' + } + }) + assert.equal(oldPassword.statusCode, 401) + + const newPassword = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { + email: 'student@example.com', + password: 'NewSecure456!' + } + }) + assert.equal(newPassword.statusCode, 200) +}) + +test('logout revokes the session and bearer authentication remains API-compatible', async (t) => { + const { app, db } = await createAuthTestApp(t) + const registration = await register(app) + const user = readBody(registration).data + const browserCookie = cookieHeader(registration) + + const loggedOut = await app.inject({ + method: 'POST', + url: '/api/v1/auth/logout', + headers: { cookie: browserCookie } + }) + assert.equal(loggedOut.statusCode, 200) + + const revoked = await app.inject({ + method: 'GET', + url: '/api/v1/auth/session', + headers: { cookie: browserCookie } + }) + assert.equal(revoked.statusCode, 401) + + const apiSession = createSession(db, user.id, { ttlSeconds: 3600 }) + const bearerResponse = await app.inject({ + method: 'GET', + url: '/api/v1/auth/session', + headers: { authorization: `Bearer ${apiSession.token}` } + }) + + assert.equal(bearerResponse.statusCode, 200) + assert.equal(readBody(bearerResponse).data.id, user.id) + assert.equal(Object.hasOwn(readBody(bearerResponse).data, 'token'), false) +}) + +test('cookie-authenticated writes reject an untrusted Origin', async (t) => { + const { app } = await createAuthTestApp(t) + const registration = await register(app) + + const response = await app.inject({ + method: 'PATCH', + url: '/api/v1/me', + headers: { + cookie: cookieHeader(registration), + origin: 'https://attacker.example' + }, + payload: { displayName: '不应被写入' } + }) + + assert.equal(response.statusCode, 403) + assert.equal(readBody(response).msg, '请求来源不受信任') +}) diff --git a/server/test/bootstrap-admin.test.js b/server/test/bootstrap-admin.test.js new file mode 100644 index 0000000..69bc9ec --- /dev/null +++ b/server/test/bootstrap-admin.test.js @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { bootstrapAdministrator } from '../db/bootstrap-admin.js' +import { createDatabase } from '../db/database.js' +import { verifyPassword } from '../lib/password.js' + +test('one-time administrator bootstrap creates a hashed privileged account and audit', async (t) => { + const database = createDatabase({ filename: ':memory:' }) + t.after(() => database.close()) + + const administrator = await bootstrapAdministrator(database, { + email: 'OWNER@EXAMPLE.TEST', + password: 'Strong-Admin-2026!', + displayName: '平台负责人' + }) + assert.equal(administrator.email, 'owner@example.test') + assert.equal(administrator.role, 'administrator') + + const row = database + .prepare('SELECT * FROM users WHERE id = ?') + .get(administrator.id) + assert.match(row.password_hash, /^scrypt\$/) + assert.equal( + await verifyPassword('Strong-Admin-2026!', row.password_hash), + true + ) + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM audit_logs + WHERE action = 'administrator.bootstrapped' AND actor_id = ?` + ) + .get(administrator.id).count, + 1 + ) +}) + +test('bootstrap refuses weak input and never overwrites an existing administrator', async (t) => { + const database = createDatabase({ filename: ':memory:' }) + t.after(() => database.close()) + + await assert.rejects( + bootstrapAdministrator(database, { + email: 'admin@example.test', + password: 'weak-password', + displayName: '管理员' + }), + (error) => error.code === 'INVALID_ADMIN_INPUT' + ) + + const first = await bootstrapAdministrator(database, { + email: 'first@example.test', + password: 'Strong-Admin-2026!', + displayName: '第一位管理员' + }) + const originalHash = database + .prepare('SELECT password_hash FROM users WHERE id = ?') + .get(first.id).password_hash + + await assert.rejects( + bootstrapAdministrator(database, { + email: 'second@example.test', + password: 'Another-Admin-2026!', + displayName: '第二位管理员' + }), + (error) => error.code === 'ADMIN_ALREADY_EXISTS' + ) + assert.equal( + database + .prepare( + "SELECT COUNT(*) AS count FROM users WHERE role = 'administrator'" + ) + .get().count, + 1 + ) + assert.equal( + database + .prepare('SELECT password_hash FROM users WHERE id = ?') + .get(first.id).password_hash, + originalHash + ) +}) diff --git a/server/test/course-workflow.test.js b/server/test/course-workflow.test.js new file mode 100644 index 0000000..2bedf53 --- /dev/null +++ b/server/test/course-workflow.test.js @@ -0,0 +1,382 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import test from 'node:test' + +import cookie from '@fastify/cookie' +import Fastify from 'fastify' + +import { createDatabase } from '../db/database.js' +import { hashPassword } from '../lib/password.js' +import { createSession } from '../lib/session.js' +import authPlugin from '../plugins/auth.js' +import adminRoutes from '../routes/admin.js' +import courseRoutes from '../routes/courses.js' + +function authorization(token) { + return { authorization: `Bearer ${token}` } +} + +async function createCourseTestApp(t) { + const db = createDatabase({ filename: ':memory:' }) + const app = Fastify({ logger: false }) + app.decorate('db', db) + + await app.register(cookie) + await authPlugin(app, { + db, + allowBearer: true, + secureCookies: false, + sessionTtlSeconds: 3600 + }) + await app.register(courseRoutes) + await app.register(adminRoutes) + + const passwordHash = await hashPassword('CourseTest123!') + const now = new Date().toISOString() + const insertUser = db.prepare( + `INSERT INTO users ( + id, email, password_hash, role, display_name, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)` + ) + + const accounts = {} + for (const [key, role, displayName] of [ + ['student', 'student', '测试学生'], + ['teacher', 'teacher', '王老师'], + ['otherTeacher', 'teacher', '李老师'], + ['administrator', 'administrator', '审核员'] + ]) { + const id = randomUUID() + insertUser.run( + id, + `${key.toLowerCase()}@course.test`, + passwordHash, + role, + displayName, + now, + now + ) + const session = createSession(db, id, { ttlSeconds: 3600 }) + accounts[key] = { + id, + role, + headers: authorization(session.token) + } + } + + const insertTeacherProfile = db.prepare( + `INSERT INTO teacher_profiles (user_id, verified_at, created_at, updated_at) + VALUES (?, ?, ?, ?)` + ) + insertTeacherProfile.run(accounts.teacher.id, now, now, now) + insertTeacherProfile.run(accounts.otherTeacher.id, now, now, now) + + await app.ready() + t.after(async () => { + await app.close() + db.close() + }) + + return { app, db, accounts, now } +} + +test('course workflow enforces ownership and closes the reject-revise-approve loop', async (t) => { + const { app, db, accounts, now } = await createCourseTestApp(t) + + const createdResponse = await app.inject({ + method: 'POST', + url: '/api/v1/courses', + headers: accounts.teacher.headers, + payload: { + title: '商务中文沟通实训', + summary: '用真实职场任务练习口语与书面沟通。', + description: '包含会议、邮件和商务洽谈三个单元。', + level: 'intermediate', + category: '商务中文', + durationMinutes: 90, + priceCents: 12900, + capacity: 24 + } + }) + + assert.equal(createdResponse.statusCode, 201) + assert.equal(createdResponse.json().code, 0) + assert.equal(createdResponse.json().data.status, 'draft') + assert.equal(createdResponse.json().data.teacherId, accounts.teacher.id) + const courseId = createdResponse.json().data.id + + const invalidId = await app.inject({ + method: 'GET', + url: '/api/v1/courses/not-a-uuid' + }) + assert.equal(invalidId.statusCode, 400) + + const anonymousBeforePublish = await app.inject({ + method: 'GET', + url: `/api/v1/courses/${courseId}` + }) + assert.equal(anonymousBeforePublish.statusCode, 404) + + const studentCatalogBeforePublish = await app.inject({ + method: 'GET', + url: '/api/v1/courses?status=draft', + headers: accounts.student.headers + }) + assert.equal(studentCatalogBeforePublish.statusCode, 200) + assert.equal(studentCatalogBeforePublish.json().data.items.length, 0) + + const ownerDrafts = await app.inject({ + method: 'GET', + url: '/api/v1/courses?status=draft', + headers: accounts.teacher.headers + }) + assert.equal(ownerDrafts.statusCode, 200) + assert.deepEqual( + ownerDrafts.json().data.items.map((course) => course.id), + [courseId] + ) + + const otherTeacherDrafts = await app.inject({ + method: 'GET', + url: '/api/v1/courses?status=draft', + headers: accounts.otherTeacher.headers + }) + assert.equal(otherTeacherDrafts.statusCode, 200) + assert.equal(otherTeacherDrafts.json().data.items.length, 0) + + const studentCreate = await app.inject({ + method: 'POST', + url: '/api/v1/courses', + headers: accounts.student.headers, + payload: { title: '越权课程' } + }) + assert.equal(studentCreate.statusCode, 403) + + const otherTeacherRead = await app.inject({ + method: 'GET', + url: `/api/v1/courses/${courseId}`, + headers: accounts.otherTeacher.headers + }) + assert.equal(otherTeacherRead.statusCode, 403) + + const otherTeacherUpdate = await app.inject({ + method: 'PATCH', + url: `/api/v1/courses/${courseId}`, + headers: accounts.otherTeacher.headers, + payload: { title: '恶意篡改' } + }) + assert.equal(otherTeacherUpdate.statusCode, 403) + + const otherTeacherSubmit = await app.inject({ + method: 'POST', + url: `/api/v1/courses/${courseId}/submit`, + headers: accounts.otherTeacher.headers + }) + assert.equal(otherTeacherSubmit.statusCode, 403) + + db.prepare( + 'UPDATE teacher_profiles SET verified_at = NULL WHERE user_id = ?' + ).run(accounts.teacher.id) + const unverifiedSubmission = await app.inject({ + method: 'POST', + url: `/api/v1/courses/${courseId}/submit`, + headers: accounts.teacher.headers + }) + assert.equal(unverifiedSubmission.statusCode, 409) + db.prepare( + 'UPDATE teacher_profiles SET verified_at = ? WHERE user_id = ?' + ).run(now, accounts.teacher.id) + + const firstSubmission = await app.inject({ + method: 'POST', + url: `/api/v1/courses/${courseId}/submit`, + headers: accounts.teacher.headers + }) + assert.equal(firstSubmission.statusCode, 200) + assert.equal(firstSubmission.json().data.status, 'pending') + + const updateWhilePending = await app.inject({ + method: 'PATCH', + url: `/api/v1/courses/${courseId}`, + headers: accounts.teacher.headers, + payload: { summary: '待审核时不允许修改' } + }) + assert.equal(updateWhilePending.statusCode, 409) + + const teacherReviewAttempt = await app.inject({ + method: 'POST', + url: `/api/v1/admin/course-reviews/${courseId}`, + headers: accounts.teacher.headers, + payload: { action: 'approve' } + }) + assert.equal(teacherReviewAttempt.statusCode, 403) + + const reviewQueue = await app.inject({ + method: 'GET', + url: '/api/v1/admin/course-reviews?status=pending', + headers: accounts.administrator.headers + }) + assert.equal(reviewQueue.statusCode, 200) + assert.equal(reviewQueue.json().data.pagination.total, 1) + assert.equal(reviewQueue.json().data.items[0].course.id, courseId) + + const rejectionWithoutNote = await app.inject({ + method: 'POST', + url: `/api/v1/admin/course-reviews/${courseId}`, + headers: accounts.administrator.headers, + payload: { action: 'reject' } + }) + assert.equal(rejectionWithoutNote.statusCode, 400) + + const rejectedResponse = await app.inject({ + method: 'POST', + url: `/api/v1/admin/course-reviews/${courseId}`, + headers: accounts.administrator.headers, + payload: { + action: 'reject', + note: '请补充课程学习目标和作业评价标准。' + } + }) + assert.equal(rejectedResponse.statusCode, 200) + assert.equal(rejectedResponse.json().data.course.status, 'rejected') + assert.equal(rejectedResponse.json().data.review.decision, 'rejected') + assert.match( + rejectedResponse.json().data.course.rejectionReason, + /作业评价标准/ + ) + assert.equal( + db + .prepare( + "SELECT COUNT(*) AS count FROM notifications WHERE user_id = ? AND type = 'course.rejected'" + ) + .get(accounts.teacher.id).count, + 1 + ) + assert.equal( + db + .prepare( + "SELECT COUNT(*) AS count FROM audit_logs WHERE entity_id = ? AND action = 'course.rejected'" + ) + .get(courseId).count, + 1 + ) + + const revisedResponse = await app.inject({ + method: 'PATCH', + url: `/api/v1/courses/${courseId}`, + headers: accounts.teacher.headers, + payload: { + summary: '已补充明确的学习目标、单元作业和评价量表。' + } + }) + assert.equal(revisedResponse.statusCode, 200) + assert.equal(revisedResponse.json().data.status, 'rejected') + assert.match(revisedResponse.json().data.summary, /评价量表/) + + const secondSubmission = await app.inject({ + method: 'POST', + url: `/api/v1/courses/${courseId}/submit`, + headers: accounts.teacher.headers + }) + assert.equal(secondSubmission.statusCode, 200) + assert.equal(secondSubmission.json().data.status, 'pending') + assert.equal(secondSubmission.json().data.rejectionReason, null) + + db.prepare( + 'UPDATE teacher_profiles SET verified_at = NULL WHERE user_id = ?' + ).run(accounts.teacher.id) + const approvalWhileUnverified = await app.inject({ + method: 'POST', + url: `/api/v1/admin/course-reviews/${courseId}`, + headers: accounts.administrator.headers, + payload: { action: 'approve', note: '不应通过' } + }) + assert.equal(approvalWhileUnverified.statusCode, 409) + db.prepare( + 'UPDATE teacher_profiles SET verified_at = ? WHERE user_id = ?' + ).run(now, accounts.teacher.id) + + const approvedResponse = await app.inject({ + method: 'POST', + url: `/api/v1/admin/course-reviews/${courseId}`, + headers: accounts.administrator.headers, + payload: { + action: 'approve', + note: '修订内容完整,同意发布。' + } + }) + assert.equal(approvedResponse.statusCode, 200) + assert.equal(approvedResponse.json().data.course.status, 'published') + assert.ok(approvedResponse.json().data.course.publishedAt) + assert.equal(approvedResponse.json().data.review.decision, 'approved') + + const duplicateReview = await app.inject({ + method: 'POST', + url: `/api/v1/admin/course-reviews/${courseId}`, + headers: accounts.administrator.headers, + payload: { action: 'approve' } + }) + assert.equal(duplicateReview.statusCode, 409) + + const studentCatalogAfterPublish = await app.inject({ + method: 'GET', + url: '/api/v1/courses?search=%E5%95%86%E5%8A%A1', + headers: accounts.student.headers + }) + assert.equal(studentCatalogAfterPublish.statusCode, 200) + assert.deepEqual( + studentCatalogAfterPublish.json().data.items.map((course) => course.id), + [courseId] + ) + + const publicDetail = await app.inject({ + method: 'GET', + url: `/api/v1/courses/${courseId}` + }) + assert.equal(publicDetail.statusCode, 200) + assert.equal(publicDetail.json().data.id, courseId) + assert.equal(publicDetail.json().data.status, 'published') + + db.prepare( + 'UPDATE teacher_profiles SET verified_at = NULL WHERE user_id = ?' + ).run(accounts.teacher.id) + const hiddenCatalog = await app.inject({ + method: 'GET', + url: '/api/v1/courses', + headers: accounts.student.headers + }) + assert.deepEqual(hiddenCatalog.json().data.items, []) + const hiddenPublicDetail = await app.inject({ + method: 'GET', + url: `/api/v1/courses/${courseId}` + }) + assert.equal(hiddenPublicDetail.statusCode, 404) + const ownerStillReadsCourse = await app.inject({ + method: 'GET', + url: `/api/v1/courses/${courseId}`, + headers: accounts.teacher.headers + }) + assert.equal(ownerStillReadsCourse.statusCode, 200) + db.prepare( + 'UPDATE teacher_profiles SET verified_at = ? WHERE user_id = ?' + ).run(now, accounts.teacher.id) + + const studentMetrics = await app.inject({ + method: 'GET', + url: '/api/v1/admin/metrics', + headers: accounts.student.headers + }) + assert.equal(studentMetrics.statusCode, 403) + + const metrics = await app.inject({ + method: 'GET', + url: '/api/v1/admin/metrics', + headers: accounts.administrator.headers + }) + assert.equal(metrics.statusCode, 200) + assert.equal(metrics.json().data.users.total, 4) + assert.equal(metrics.json().data.courses.total, 1) + assert.equal(metrics.json().data.courses.published, 1) + assert.equal(metrics.json().data.reviews.total, 2) + assert.ok(metrics.json().data.recentActivity.length >= 4) +}) diff --git a/server/test/database.test.js b/server/test/database.test.js new file mode 100644 index 0000000..5231b8c --- /dev/null +++ b/server/test/database.test.js @@ -0,0 +1,341 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' + +import Database from 'better-sqlite3' + +import { + createDatabase, + MIGRATION_IDS, + migrateDatabase +} from '../db/database.js' +import { + DEMO_ACCOUNTS, + DEMO_PASSWORD, + seedDatabase, + verifyPasswordHash +} from '../db/seed.js' + +const requiredTables = [ + 'users', + 'teacher_profiles', + 'sessions', + 'verification_codes', + 'courses', + 'course_reviews', + 'appointments', + 'classrooms', + 'assignments', + 'assignment_questions', + 'submissions', + 'chat_messages', + 'notifications', + 'audit_logs', + 'files', + 'dialogue_sessions', + 'dialogue_turns' +] + +function createTemporaryDatabase(t, options = {}) { + const directory = mkdtempSync( + join(tmpdir(), 'international-chinese-platform-') + ) + const filename = join(directory, 'test.db') + const database = createDatabase({ filename, ...options }) + + t.after(() => { + if (database.open) { + database.close() + } + rmSync(directory, { recursive: true, force: true }) + }) + + return database +} + +test('database migration creates the complete schema with safe pragmas', (t) => { + const database = createTemporaryDatabase(t) + + assert.equal(database.pragma('foreign_keys', { simple: true }), 1) + assert.equal(database.pragma('journal_mode', { simple: true }), 'wal') + + const tables = new Set( + database + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((row) => row.name) + ) + + for (const table of requiredTables) { + assert.ok(tables.has(table), `expected migration to create ${table}`) + } + + assert.deepEqual( + database + .prepare('SELECT id FROM schema_migrations ORDER BY id') + .all() + .map((row) => row.id), + [...MIGRATION_IDS] + ) + + migrateDatabase(database) + assert.equal( + database.prepare('SELECT COUNT(*) AS count FROM schema_migrations').get() + .count, + MIGRATION_IDS.length, + 'migrations should be repeatable' + ) +}) + +test('legacy database upgrades in order without losing classroom messages', (t) => { + const directory = mkdtempSync(join(tmpdir(), 'international-chinese-legacy-')) + const filename = join(directory, 'legacy.db') + const legacy = new Database(filename) + legacy.pragma('foreign_keys = ON') + legacy.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL, + display_name TEXT NOT NULL, + avatar_url TEXT, + country TEXT, + region TEXT, + age INTEGER, + chinese_level TEXT, + bio TEXT, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE appointments ( + id TEXT PRIMARY KEY, + student_id TEXT NOT NULL REFERENCES users(id), + teacher_id TEXT NOT NULL REFERENCES users(id), + course_id TEXT, + scheduled_start TEXT NOT NULL, + scheduled_end TEXT NOT NULL, + topic TEXT NOT NULL, + message TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + response_note TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE classrooms ( + id TEXT PRIMARY KEY, + appointment_id TEXT NOT NULL UNIQUE REFERENCES appointments(id), + room_code TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'scheduled', + opened_at TEXT, + closed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE chat_messages ( + id TEXT PRIMARY KEY, + classroom_id TEXT NOT NULL REFERENCES classrooms(id), + sender_id TEXT REFERENCES users(id), + message_type TEXT NOT NULL DEFAULT 'text', + content TEXT NOT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + edited_at TEXT, + deleted_at TEXT + ); + `) + + const now = '2025-01-01T00:00:00.000Z' + const studentId = '10000000-0000-4000-8000-000000000001' + const teacherId = '10000000-0000-4000-8000-000000000002' + legacy + .prepare( + `INSERT INTO users ( + id, email, password_hash, role, display_name, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .run( + studentId, + 'legacy@student.test', + 'x'.repeat(64), + 'student', + '旧学生', + now, + now + ) + legacy + .prepare( + `INSERT INTO users ( + id, email, password_hash, role, display_name, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .run( + teacherId, + 'legacy@teacher.test', + 'x'.repeat(64), + 'teacher', + '旧教师', + now, + now + ) + legacy + .prepare( + `INSERT INTO appointments ( + id, student_id, teacher_id, scheduled_start, scheduled_end, topic, + status, created_at, updated_at + ) VALUES ('20000000-0000-4000-8000-000000000001', ?, ?, ?, ?, '旧课堂', + 'accepted', ?, ?)` + ) + .run(studentId, teacherId, now, '2025-01-01T01:00:00.000Z', now, now) + legacy.exec(` + INSERT INTO classrooms ( + id, appointment_id, room_code, status, created_at, updated_at + ) VALUES ( + '30000000-0000-4000-8000-000000000001', + '20000000-0000-4000-8000-000000000001', + 'legacy-room', 'open', '${now}', '${now}' + ); + INSERT INTO chat_messages ( + id, classroom_id, sender_id, content, created_at + ) VALUES ( + '40000000-0000-4000-8000-000000000001', + '30000000-0000-4000-8000-000000000001', + '${studentId}', '迁移前的课堂消息', '${now}' + ); + `) + legacy.close() + + const upgraded = createDatabase({ filename }) + t.after(() => { + if (upgraded.open) upgraded.close() + rmSync(directory, { recursive: true, force: true }) + }) + + assert.equal( + upgraded + .prepare('SELECT content FROM chat_messages WHERE id = ?') + .get('40000000-0000-4000-8000-000000000001').content, + '迁移前的课堂消息' + ) + assert.ok( + upgraded + .prepare('PRAGMA table_info(chat_messages)') + .all() + .some((column) => column.name === 'client_message_id') + ) + for (const table of ['teacher_profiles', 'files', 'dialogue_sessions']) { + assert.ok( + upgraded + .prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?" + ) + .get(table), + `expected legacy migration to create ${table}` + ) + } + assert.deepEqual( + upgraded + .prepare('SELECT id FROM schema_migrations ORDER BY id') + .all() + .map((row) => row.id), + [...MIGRATION_IDS] + ) +}) + +test('demo seed is idempotent and stores all account passwords as scrypt hashes', (t) => { + const database = createTemporaryDatabase(t) + + const firstResult = seedDatabase(database) + const firstCounts = Object.fromEntries( + requiredTables.map((table) => [ + table, + database.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get().count + ]) + ) + const firstHashes = database + .prepare('SELECT email, password_hash FROM users ORDER BY email') + .all() + + const secondResult = seedDatabase(database) + const secondCounts = Object.fromEntries( + requiredTables.map((table) => [ + table, + database.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get().count + ]) + ) + const secondHashes = database + .prepare('SELECT email, password_hash FROM users ORDER BY email') + .all() + + assert.deepEqual(secondResult, firstResult) + assert.deepEqual(secondCounts, firstCounts) + assert.deepEqual( + secondHashes, + firstHashes, + 'rerunning seed must not rotate existing hashes' + ) + + for (const account of DEMO_ACCOUNTS) { + const row = database + .prepare('SELECT role, password_hash FROM users WHERE email = ?') + .get(account.email) + + assert.equal(row.role, account.role) + assert.notEqual(row.password_hash, DEMO_PASSWORD) + assert.match(row.password_hash, /^scrypt\$/) + assert.equal(verifyPasswordHash(DEMO_PASSWORD, row.password_hash), true) + assert.equal( + verifyPasswordHash('not-the-demo-password', row.password_hash), + false + ) + } + + assert.equal(firstCounts.courses, 2) + assert.equal(firstCounts.appointments, 2) + assert.equal(firstCounts.assignments, 1) + assert.equal(firstCounts.assignment_questions, 2) + assert.equal(firstCounts.submissions, 1) +}) + +test('foreign keys and domain checks reject invalid records', (t) => { + const database = createTemporaryDatabase(t) + + assert.throws( + () => + database + .prepare( + ` + INSERT INTO courses (id, teacher_id, title) + VALUES (?, ?, ?) + ` + ) + .run( + 'f0000000-0000-4000-8000-000000000001', + 'f0000000-0000-4000-8000-000000000002', + 'Orphan course' + ), + /FOREIGN KEY constraint failed/ + ) + + assert.throws( + () => + database + .prepare( + ` + INSERT INTO users (id, email, password_hash, role, display_name) + VALUES (?, ?, ?, ?, ?) + ` + ) + .run( + 'f0000000-0000-4000-8000-000000000003', + 'invalid-role@example.com', + 'x'.repeat(64), + 'owner', + 'Invalid role' + ), + /CHECK constraint failed/ + ) +}) diff --git a/server/test/dialogues.test.js b/server/test/dialogues.test.js new file mode 100644 index 0000000..c0d96af --- /dev/null +++ b/server/test/dialogues.test.js @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import test from 'node:test' + +import cookie from '@fastify/cookie' +import Fastify from 'fastify' + +import { createDatabase } from '../db/database.js' +import { hashPassword } from '../lib/password.js' +import { createSession } from '../lib/session.js' +import authPlugin from '../plugins/auth.js' +import dialogueRoutes from '../routes/dialogues.js' + +function authorization(token) { + return { authorization: `Bearer ${token}` } +} + +async function createTestApp(t) { + const db = createDatabase({ filename: ':memory:' }) + const app = Fastify({ logger: false }) + app.decorate('db', db) + app.decorate('config', { aiApiUrl: '', aiApiKey: '' }) + + await app.register(cookie) + await authPlugin(app, { + db, + allowBearer: true, + secureCookies: false, + sessionTtlSeconds: 3600 + }) + await app.register(dialogueRoutes) + + const now = new Date().toISOString() + const passwordHash = await hashPassword('DialogueTest123!') + const users = {} + for (const [name, role] of [ + ['student', 'student'], + ['other', 'student'], + ['teacher', 'teacher'] + ]) { + const id = randomUUID() + db.prepare( + `INSERT INTO users ( + id, email, password_hash, role, display_name, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)` + ).run(id, `${name}@dialogue.test`, passwordHash, role, name, now, now) + users[name] = { + id, + token: createSession(db, id, { ttlSeconds: 3600 }).token + } + } + + await app.ready() + t.after(async () => { + await app.close() + db.close() + }) + return { app, db, users } +} + +test('student can generate, continue and revisit a persistent local dialogue', async (t) => { + const { app, db, users } = await createTestApp(t) + const headers = authorization(users.student.token) + + const created = await app.inject({ + method: 'POST', + url: '/api/v1/dialogues', + headers, + payload: { keywords: ['点餐', '餐厅', '价格'] } + }) + assert.equal(created.statusCode, 201) + assert.equal(created.json().data.provider, 'local') + assert.deepEqual(created.json().data.keywords, ['点餐', '餐厅', '价格']) + assert.equal(created.json().data.turns.length, 2) + const dialogueId = created.json().data.id + + const continued = await app.inject({ + method: 'POST', + url: `/api/v1/dialogues/${dialogueId}/messages`, + headers, + payload: { message: '我想在餐厅练习点餐。' } + }) + assert.equal(continued.statusCode, 200) + assert.equal(continued.json().data.turns.length, 2) + assert.equal(continued.json().data.turns[0].speaker, 'student') + assert.equal(continued.json().data.turns[1].speaker, 'tutor') + assert.match(continued.json().data.turns[1].content, /点餐/) + + const detail = await app.inject({ + method: 'GET', + url: `/api/v1/dialogues/${dialogueId}`, + headers + }) + assert.equal(detail.statusCode, 200) + assert.equal(detail.json().data.turns.length, 4) + assert.deepEqual( + detail.json().data.turns.map((turn) => turn.position), + [1, 2, 3, 4] + ) + + const list = await app.inject({ + method: 'GET', + url: '/api/v1/dialogues', + headers + }) + assert.equal(list.statusCode, 200) + assert.equal(list.json().data.items.length, 1) + assert.equal(list.json().data.items[0].turnCount, 4) + assert.equal( + db + .prepare( + 'SELECT COUNT(*) AS count FROM dialogue_turns WHERE session_id = ?' + ) + .get(dialogueId).count, + 4 + ) +}) + +test('dialogue ownership and student role are enforced without leaking existence', async (t) => { + const { app, users } = await createTestApp(t) + const created = await app.inject({ + method: 'POST', + url: '/api/v1/dialogues', + headers: authorization(users.student.token), + payload: { title: '机场练习', keywords: ['机场', '行李'] } + }) + const dialogueId = created.json().data.id + + for (const request of [ + { + method: 'GET', + url: `/api/v1/dialogues/${dialogueId}`, + headers: authorization(users.other.token) + }, + { + method: 'POST', + url: `/api/v1/dialogues/${dialogueId}/messages`, + headers: authorization(users.other.token), + payload: { message: '尝试越权写入' } + } + ]) { + const response = await app.inject(request) + assert.equal(response.statusCode, 404) + } + + const teacher = await app.inject({ + method: 'GET', + url: '/api/v1/dialogues', + headers: authorization(users.teacher.token) + }) + assert.equal(teacher.statusCode, 403) +}) diff --git a/server/test/files.test.js b/server/test/files.test.js new file mode 100644 index 0000000..9eb6469 --- /dev/null +++ b/server/test/files.test.js @@ -0,0 +1,375 @@ +import assert from 'node:assert/strict' +import { Buffer } from 'node:buffer' +import { randomUUID } from 'node:crypto' +import { mkdtemp, readdir, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import test from 'node:test' + +import cookie from '@fastify/cookie' +import multipart from '@fastify/multipart' +import Fastify from 'fastify' + +import { createDatabase } from '../db/database.js' +import { hashPassword } from '../lib/password.js' +import { createSession } from '../lib/session.js' +import authPlugin from '../plugins/auth.js' +import fileRoutes from '../routes/files.js' +import { loadConfig } from '../config.js' + +function authorization(token) { + return { authorization: `Bearer ${token}` } +} + +function multipartBody({ content, filename, mimeType }) { + const boundary = `test-${randomUUID()}` + const prefix = Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` + + `Content-Type: ${mimeType}\r\n\r\n` + ) + const suffix = Buffer.from(`\r\n--${boundary}--\r\n`) + return { + body: Buffer.concat([prefix, content, suffix]), + contentType: `multipart/form-data; boundary=${boundary}` + } +} + +function pngContent(sizeBytes = 48) { + const header = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52 + ]) + assert.ok(sizeBytes >= header.length) + return Buffer.concat([header, Buffer.alloc(sizeBytes - header.length, 0x61)]) +} + +async function createTestApp(t, uploadConfig = {}) { + const uploadDir = await mkdtemp(join(tmpdir(), 'chinese-platform-files-')) + const db = createDatabase({ filename: ':memory:' }) + const app = Fastify({ logger: false }) + app.decorate('db', db) + app.decorate('config', { + uploadDir, + uploadOwnerQuotaBytes: 250 * 1024 * 1024, + uploadTotalQuotaBytes: 5 * 1024 * 1024 * 1024, + uploadMaxConcurrent: 4, + ...uploadConfig + }) + + await app.register(cookie) + await app.register(multipart, { + limits: { files: 1, fileSize: 50 * 1024 * 1024, fields: 10 } + }) + await authPlugin(app, { + db, + allowBearer: true, + secureCookies: false, + sessionTtlSeconds: 3600 + }) + await app.register(fileRoutes) + + const now = new Date().toISOString() + const passwordHash = await hashPassword('FileTest123!') + const users = {} + for (const [name, role] of [ + ['owner', 'teacher'], + ['other', 'teacher'], + ['student', 'student'] + ]) { + const id = randomUUID() + db.prepare( + `INSERT INTO users ( + id, email, password_hash, role, display_name, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)` + ).run(id, `${name}@file.test`, passwordHash, role, name, now, now) + users[name] = { + id, + token: createSession(db, id, { ttlSeconds: 3600 }).token + } + } + + await app.ready() + t.after(async () => { + await app.close() + db.close() + await rm(uploadDir, { recursive: true, force: true }) + }) + + return { app, db, uploadDir, users } +} + +test('validated image upload is public while metadata and deletion remain owned', async (t) => { + const { app, db, users } = await createTestApp(t) + const png = pngContent() + const form = multipartBody({ + content: png, + filename: '课程封面.png', + mimeType: 'image/png' + }) + + const upload = await app.inject({ + method: 'POST', + url: '/api/v1/files?category=course_cover', + headers: { + ...authorization(users.owner.token), + 'content-type': form.contentType + }, + payload: form.body + }) + assert.equal(upload.statusCode, 201) + + assert.equal(upload.json().data.mimeType, 'image/png') + assert.equal(upload.json().data.ownerId, users.owner.id) + assert.equal(upload.json().data.sha256.length, 64) + const fileId = upload.json().data.id + + const content = await app.inject({ + method: 'GET', + url: `/api/v1/files/${fileId}/content` + }) + assert.equal(content.statusCode, 200) + assert.equal(content.headers['content-type'], 'image/png') + assert.deepEqual(content.rawPayload, png) + + const otherMetadata = await app.inject({ + method: 'GET', + url: `/api/v1/files/${fileId}`, + headers: authorization(users.other.token) + }) + assert.equal(otherMetadata.statusCode, 403) + + const otherDelete = await app.inject({ + method: 'DELETE', + url: `/api/v1/files/${fileId}`, + headers: authorization(users.other.token) + }) + assert.equal(otherDelete.statusCode, 403) + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM files').get().count, 1) + + const ownerDelete = await app.inject({ + method: 'DELETE', + url: `/api/v1/files/${fileId}`, + headers: authorization(users.owner.token) + }) + assert.equal(ownerDelete.statusCode, 200) + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM files').get().count, 0) +}) + +test('magic-byte validation rejects disguised files and protects private material', async (t) => { + const { app, db, uploadDir, users } = await createTestApp(t) + const disguised = multipartBody({ + content: Buffer.from('%PDF-1.7 disguised as an image'), + filename: 'avatar.png', + mimeType: 'image/png' + }) + const invalid = await app.inject({ + method: 'POST', + url: '/api/v1/files?category=avatar', + headers: { + ...authorization(users.owner.token), + 'content-type': disguised.contentType + }, + payload: disguised.body + }) + assert.equal(invalid.statusCode, 415) + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM files').get().count, 0) + assert.deepEqual(await readdir(join(uploadDir, '.tmp')), []) + + const signatureOnly = multipartBody({ + content: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + filename: 'truncated.png', + mimeType: 'image/png' + }) + const truncatedHeader = await app.inject({ + method: 'POST', + url: '/api/v1/files?category=avatar', + headers: { + ...authorization(users.owner.token), + 'content-type': signatureOnly.contentType + }, + payload: signatureOnly.body + }) + assert.equal(truncatedHeader.statusCode, 415) + assert.deepEqual(await readdir(join(uploadDir, '.tmp')), []) + + const pdf = Buffer.from('%PDF-1.7 portfolio material') + const material = multipartBody({ + content: pdf, + filename: 'lesson.pdf', + mimeType: 'application/pdf' + }) + const forbiddenStudentUpload = await app.inject({ + method: 'POST', + url: '/api/v1/files?category=course_material', + headers: { + ...authorization(users.student.token), + 'content-type': material.contentType + }, + payload: material.body + }) + assert.equal(forbiddenStudentUpload.statusCode, 403) + + const upload = await app.inject({ + method: 'POST', + url: '/api/v1/files?category=course_material', + headers: { + ...authorization(users.owner.token), + 'content-type': material.contentType + }, + payload: material.body + }) + assert.equal(upload.statusCode, 201) + + const anonymousContent = await app.inject({ + method: 'GET', + url: upload.json().data.url + }) + assert.equal(anonymousContent.statusCode, 401) + + const authenticatedContent = await app.inject({ + method: 'GET', + url: upload.json().data.url, + headers: authorization(users.other.token) + }) + assert.equal(authenticatedContent.statusCode, 403) + + const ownerContent = await app.inject({ + method: 'GET', + url: upload.json().data.url, + headers: authorization(users.owner.token) + }) + assert.equal(ownerContent.statusCode, 200) + assert.deepEqual(ownerContent.rawPayload, pdf) +}) + +test('concurrent reservations prevent account and platform quota bypass', async (t) => { + const ownerLimited = await createTestApp(t, { + uploadOwnerQuotaBytes: 70, + uploadTotalQuotaBytes: 200, + uploadMaxConcurrent: 2 + }) + const png = pngContent(48) + const ownerUploads = await Promise.all( + ['first.png', 'second.png'].map(async (filename) => { + const form = multipartBody({ + content: png, + filename, + mimeType: 'image/png' + }) + return ownerLimited.app.inject({ + method: 'POST', + url: '/api/v1/files?category=avatar', + headers: { + ...authorization(ownerLimited.users.owner.token), + 'content-type': form.contentType + }, + payload: form.body + }) + }) + ) + + assert.deepEqual( + ownerUploads.map((response) => response.statusCode).sort(), + [201, 413] + ) + assert.equal( + ownerLimited.db.prepare('SELECT SUM(size_bytes) AS total FROM files').get() + .total, + 48 + ) + assert.deepEqual(await readdir(join(ownerLimited.uploadDir, '.tmp')), []) + + const platformLimited = await createTestApp(t, { + uploadOwnerQuotaBytes: 60, + uploadTotalQuotaBytes: 80, + uploadMaxConcurrent: 2 + }) + const platformUploads = await Promise.all( + [platformLimited.users.owner, platformLimited.users.other].map( + async (user, index) => { + const form = multipartBody({ + content: png, + filename: `platform-${index}.png`, + mimeType: 'image/png' + }) + return platformLimited.app.inject({ + method: 'POST', + url: '/api/v1/files?category=avatar', + headers: { + ...authorization(user.token), + 'content-type': form.contentType + }, + payload: form.body + }) + } + ) + ) + + assert.deepEqual( + platformUploads.map((response) => response.statusCode).sort(), + [201, 507] + ) + assert.equal( + platformLimited.db + .prepare('SELECT SUM(size_bytes) AS total FROM files') + .get().total, + 48 + ) + assert.deepEqual(await readdir(join(platformLimited.uploadDir, '.tmp')), []) +}) + +test('the upload admission limit applies fail-fast process backpressure', async (t) => { + const { app, db, uploadDir, users } = await createTestApp(t, { + uploadOwnerQuotaBytes: 3 * 1024 * 1024, + uploadTotalQuotaBytes: 6 * 1024 * 1024, + uploadMaxConcurrent: 1 + }) + const png = pngContent(1024 * 1024) + const responses = await Promise.all( + ['one.png', 'two.png'].map(async (filename) => { + const form = multipartBody({ + content: png, + filename, + mimeType: 'image/png' + }) + return app.inject({ + method: 'POST', + url: '/api/v1/files?category=avatar', + headers: { + ...authorization(users.owner.token), + 'content-type': form.contentType + }, + payload: form.body + }) + }) + ) + + assert.deepEqual( + responses.map((response) => response.statusCode).sort(), + [201, 503] + ) + assert.equal( + responses.find((response) => response.statusCode === 503).headers[ + 'retry-after' + ], + '1' + ) + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM files').get().count, 1) + assert.deepEqual(await readdir(join(uploadDir, '.tmp')), []) +}) + +test('upload resource configuration rejects malformed or unsafe limits', () => { + assert.throws( + () => loadConfig({ UPLOAD_MAX_CONCURRENT: '4workers' }), + /UPLOAD_MAX_CONCURRENT/ + ) + assert.throws( + () => + loadConfig({ + UPLOAD_OWNER_QUOTA_BYTES: '1024', + UPLOAD_TOTAL_QUOTA_BYTES: '512' + }), + /UPLOAD_TOTAL_QUOTA_BYTES/ + ) +}) diff --git a/server/test/health.test.js b/server/test/health.test.js new file mode 100644 index 0000000..8fc6d3d --- /dev/null +++ b/server/test/health.test.js @@ -0,0 +1,221 @@ +import assert from 'node:assert/strict' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, test } from 'node:test' + +import { buildApp } from '../app.js' + +const apps = [] +const tempDirectories = [] + +afterEach(async () => { + await Promise.all(apps.splice(0).map((app) => app.close())) + await Promise.all( + tempDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ) +}) + +async function createApp(options = {}) { + const app = await buildApp({ + config: { + logger: false, + rateLimitMax: 1000, + ...options.config + }, + database: options.database + }) + + apps.push(app) + return app +} + +test('GET /api/v1/health returns the stable success envelope', async () => { + const app = await createApp() + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/health' + }) + + assert.equal(response.statusCode, 200) + assert.match(response.headers['content-type'], /^application\/json/) + assert.equal(response.headers['x-content-type-options'], 'nosniff') + + const body = response.json() + assert.equal(body.code, 0) + assert.equal(body.msg, 'healthy') + assert.equal(body.data.status, 'ok') + assert.equal(body.data.service, 'international-chinese-platform-api') + assert.equal(typeof body.data.uptimeSeconds, 'number') + assert.ok(Number.isFinite(Date.parse(body.data.timestamp))) +}) + +test('GET /api/v1/ready checks the injected database', async () => { + let probeCount = 0 + const database = { + prepare(sql) { + assert.equal(sql, 'SELECT 1 AS ok') + return { + get() { + probeCount += 1 + return { ok: 1 } + } + } + } + } + const app = await createApp({ database }) + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/ready' + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { + code: 0, + msg: 'ready', + data: { + status: 'ready', + checks: { database: 'up' } + } + }) + assert.equal(probeCount, 1) +}) + +test('GET /api/v1/ready fails when no database is configured', async () => { + const app = await createApp() + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/ready' + }) + + assert.equal(response.statusCode, 503) + assert.deepEqual(response.json(), { + code: 'SERVICE_UNAVAILABLE', + msg: 'Service is not ready', + data: { + status: 'not_ready', + checks: { database: 'not_configured' } + } + }) +}) + +test('GET /api/v1/ready returns a sanitized failure when the database is down', async () => { + const database = { + prepare() { + throw new Error('secret database path and stack details') + } + } + const app = await createApp({ database }) + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/ready' + }) + + assert.equal(response.statusCode, 503) + assert.deepEqual(response.json(), { + code: 'SERVICE_UNAVAILABLE', + msg: 'Service is not ready', + data: { + status: 'not_ready', + checks: { database: 'down' } + } + }) + assert.doesNotMatch(response.body, /secret|stack|\.data/i) +}) + +test('unknown API routes use the unified 404 envelope', async () => { + const app = await createApp() + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/does-not-exist' + }) + + assert.equal(response.statusCode, 404) + assert.deepEqual(response.json(), { + code: 'NOT_FOUND', + msg: 'Route not found', + data: null + }) +}) + +test('unexpected errors use the unified envelope without leaking details', async () => { + const app = await createApp() + app.get('/api/v1/test-error', async () => { + const error = new Error('sensitive implementation detail') + error.stack = 'TOP_SECRET_STACK' + throw error + }) + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/test-error' + }) + + assert.equal(response.statusCode, 500) + assert.deepEqual(response.json(), { + code: 'INTERNAL_ERROR', + msg: 'Internal server error', + data: null + }) + assert.doesNotMatch(response.body, /sensitive|TOP_SECRET|stack/i) +}) + +test('the built SPA is served only in production', async () => { + const distDir = await mkdtemp(join(tmpdir(), 'icp-dist-')) + tempDirectories.push(distDir) + await writeFile( + join(distDir, 'index.html'), + '
International Chinese
' + ) + + const developmentApp = await createApp({ + config: { distDir, nodeEnv: 'development' } + }) + const developmentResponse = await developmentApp.inject({ + method: 'GET', + url: '/student/home', + headers: { accept: 'text/html' } + }) + + assert.equal(developmentResponse.statusCode, 404) + assert.equal(developmentResponse.json().code, 'NOT_FOUND') + + const productionApp = await createApp({ + config: { distDir, nodeEnv: 'production', rateLimitMax: 1 } + }) + const productionResponse = await productionApp.inject({ + method: 'GET', + url: '/student/home', + headers: { accept: 'text/html' } + }) + + assert.equal(productionResponse.statusCode, 200) + assert.match(productionResponse.headers['content-type'], /^text\/html/) + assert.equal(productionResponse.body, '
International Chinese
') + + const repeatedNavigation = await productionApp.inject({ + method: 'GET', + url: '/student/home', + headers: { accept: 'text/html' } + }) + assert.equal(repeatedNavigation.statusCode, 200) + + const firstApiResponse = await productionApp.inject({ + method: 'GET', + url: '/api/v1/health' + }) + const rateLimitedApiResponse = await productionApp.inject({ + method: 'GET', + url: '/api/v1/health' + }) + + assert.equal(firstApiResponse.statusCode, 200) + assert.equal(rateLimitedApiResponse.statusCode, 429) +}) diff --git a/server/test/mail-provider.test.js b/server/test/mail-provider.test.js new file mode 100644 index 0000000..affaa0b --- /dev/null +++ b/server/test/mail-provider.test.js @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { createMailProvider } from '../services/mail-provider.js' + +test('SMTP mail provider sends the registration code with hardened transport options', async () => { + let transportOptions + let message + const provider = createMailProvider({ + smtpUrl: 'smtps://mailer:secret@smtp.example.test:465', + mailFrom: 'Platform ', + transportFactory(options) { + transportOptions = options + return { + async sendMail(payload) { + message = payload + return { messageId: 'test-message-id' } + } + } + } + }) + + const result = await provider.sendVerificationCode({ + email: 'student@example.test', + code: '123456', + expiresAt: '2030-01-01T12:00:00.000Z' + }) + + assert.equal(result.messageId, 'test-message-id') + assert.equal(transportOptions.disableFileAccess, true) + assert.equal(transportOptions.disableUrlAccess, true) + assert.equal(transportOptions.requireTLS, true) + assert.equal(message.to, 'student@example.test') + assert.equal(message.from, 'Platform ') + assert.match(message.text, /123456/) + assert.match(message.html, /123456/) +}) + +test('mail provider stays disabled when SMTP is not configured', () => { + assert.equal(createMailProvider({ smtpUrl: '' }), null) +}) diff --git a/server/test/notifications.test.js b/server/test/notifications.test.js new file mode 100644 index 0000000..cc2edc4 --- /dev/null +++ b/server/test/notifications.test.js @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import test from 'node:test' + +import cookie from '@fastify/cookie' +import Fastify from 'fastify' + +import { createDatabase } from '../db/database.js' +import { hashPassword } from '../lib/password.js' +import { createSession } from '../lib/session.js' +import authPlugin from '../plugins/auth.js' +import notificationRoutes from '../routes/notifications.js' + +async function createTestApp(t) { + const db = createDatabase({ filename: ':memory:' }) + const app = Fastify({ logger: false }) + app.decorate('db', db) + + await app.register(cookie) + await authPlugin(app, { + db, + allowBearer: true, + secureCookies: false, + sessionTtlSeconds: 3600 + }) + await app.register(notificationRoutes) + + const now = new Date().toISOString() + const passwordHash = await hashPassword('Notification123!') + const users = {} + for (const name of ['owner', 'other']) { + const id = randomUUID() + db.prepare( + `INSERT INTO users ( + id, email, password_hash, role, display_name, status, created_at, updated_at + ) VALUES (?, ?, ?, 'student', ?, 'active', ?, ?)` + ).run(id, `${name}@notification.test`, passwordHash, name, now, now) + users[name] = { + id, + token: createSession(db, id, { ttlSeconds: 3600 }).token + } + } + + const ids = [randomUUID(), randomUUID(), randomUUID()] + const insert = db.prepare( + `INSERT INTO notifications ( + id, user_id, type, title, body, link, read_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + insert.run( + ids[0], + users.owner.id, + 'course.approved', + '课程审核通过', + '你的课程已发布。', + '/teacher/courses', + null, + now + ) + insert.run( + ids[1], + users.owner.id, + 'submission.graded', + '作业已批阅', + '查看本次成绩。', + '/student/homeWork', + now, + now + ) + insert.run( + ids[2], + users.other.id, + 'private', + '他人的通知', + '不可见', + null, + null, + now + ) + + await app.ready() + t.after(async () => { + await app.close() + db.close() + }) + + return { app, db, users, ids } +} + +function authorization(token) { + return { authorization: `Bearer ${token}` } +} + +test('notification list is paginated and scoped to the current user', async (t) => { + const { app, users } = await createTestApp(t) + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/notifications?status=all&page=1&pageSize=20', + headers: authorization(users.owner.token) + }) + + assert.equal(response.statusCode, 200) + assert.equal(response.json().data.items.length, 2) + assert.equal(response.json().data.unread, 1) + assert.equal(response.json().data.pagination.total, 2) + assert.equal( + response.json().data.items.some((item) => item.title === '他人的通知'), + false + ) +}) + +test('read operations are idempotent and cannot modify another user notification', async (t) => { + const { app, db, users, ids } = await createTestApp(t) + const headers = authorization(users.owner.token) + + const forbidden = await app.inject({ + method: 'PATCH', + url: `/api/v1/notifications/${ids[2]}/read`, + headers + }) + assert.equal(forbidden.statusCode, 404) + assert.equal( + db.prepare('SELECT read_at FROM notifications WHERE id = ?').get(ids[2]) + .read_at, + null + ) + + for (let index = 0; index < 2; index += 1) { + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/notifications/${ids[0]}/read`, + headers + }) + assert.equal(response.statusCode, 200) + assert.equal(response.json().data.isRead, true) + } + + const readAll = await app.inject({ + method: 'POST', + url: '/api/v1/notifications/read-all', + headers + }) + assert.equal(readAll.statusCode, 200) + assert.equal(readAll.json().data.unread, 0) + assert.equal( + db + .prepare( + 'SELECT COUNT(*) AS count FROM notifications WHERE user_id = ? AND read_at IS NULL' + ) + .get(users.owner.id).count, + 0 + ) +}) diff --git a/server/test/profile.test.js b/server/test/profile.test.js new file mode 100644 index 0000000..a67209f --- /dev/null +++ b/server/test/profile.test.js @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import test from 'node:test' + +import cookie from '@fastify/cookie' +import Fastify from 'fastify' + +import { createDatabase } from '../db/database.js' +import { hashPassword } from '../lib/password.js' +import { createSession } from '../lib/session.js' +import authPlugin from '../plugins/auth.js' +import profileRoutes from '../routes/profile.js' + +function authorization(token) { + return { authorization: `Bearer ${token}` } +} + +async function createTestApp(t) { + const db = createDatabase({ filename: ':memory:' }) + const app = Fastify({ logger: false }) + app.decorate('db', db) + await app.register(cookie) + await authPlugin(app, { + db, + allowBearer: true, + secureCookies: false, + sessionTtlSeconds: 3600 + }) + await app.register(profileRoutes) + + const now = new Date().toISOString() + const passwordHash = await hashPassword('ProfileTest123!') + const users = {} + for (const [name, role] of [ + ['student', 'student'], + ['teacher', 'teacher'] + ]) { + const id = randomUUID() + db.prepare( + `INSERT INTO users ( + id, email, password_hash, role, display_name, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)` + ).run(id, `${name}@profile.test`, passwordHash, role, name, now, now) + users[name] = { + id, + token: createSession(db, id, { ttlSeconds: 3600 }).token + } + } + db.prepare( + `INSERT INTO teacher_profiles ( + user_id, school, title, experience_years, specialties_json, + certificates_json, teaching_style_json, languages_json, + verified_at, created_at, updated_at + ) VALUES (?, '初始学校', '讲师', 2, '["口语"]', '[]', '[]', '["中文"]', ?, ?, ?)` + ).run(users.teacher.id, now, now, now) + + await app.ready() + t.after(async () => { + await app.close() + db.close() + }) + return { app, db, users } +} + +test('teacher can read and update professional profile fields atomically', async (t) => { + const { app, db, users } = await createTestApp(t) + const headers = authorization(users.teacher.token) + + const initial = await app.inject({ + method: 'GET', + url: '/api/v1/me', + headers + }) + assert.equal(initial.statusCode, 200) + assert.equal(initial.json().data.teacherProfile.school, '初始学校') + assert.deepEqual(initial.json().data.teacherProfile.specialties, ['口语']) + assert.ok(initial.json().data.teacherProfile.verifiedAt) + + const updated = await app.inject({ + method: 'PATCH', + url: '/api/v1/me', + headers, + payload: { + displayName: '林老师', + bio: '帮助学习者自信表达。', + teacherProfile: { + school: '国际中文学院', + experienceYears: 6, + hourlyRateCents: 18800, + specialties: ['HSK', '商务中文'], + teachingStyle: ['任务驱动'], + languages: ['中文', '英语'] + } + } + }) + assert.equal(updated.statusCode, 200) + assert.equal(updated.json().data.displayName, '林老师') + assert.equal(updated.json().data.teacherProfile.experienceYears, 6) + assert.deepEqual(updated.json().data.teacherProfile.specialties, [ + 'HSK', + '商务中文' + ]) + assert.equal(updated.json().data.teacherProfile.verifiedAt, null) + assert.equal( + db + .prepare( + 'SELECT hourly_rate_cents FROM teacher_profiles WHERE user_id = ?' + ) + .get(users.teacher.id).hourly_rate_cents, + 18800 + ) + assert.equal( + db + .prepare( + `SELECT COUNT(*) AS count + FROM audit_logs + WHERE action = 'teacher.verification.reset' AND entity_id = ?` + ) + .get(users.teacher.id).count, + 1 + ) +}) + +test('student cannot write teacher profile fields', async (t) => { + const { app, db, users } = await createTestApp(t) + const response = await app.inject({ + method: 'PATCH', + url: '/api/v1/me', + headers: authorization(users.student.token), + payload: { teacherProfile: { school: '伪造学校' } } + }) + assert.equal(response.statusCode, 403) + assert.equal( + db + .prepare( + 'SELECT COUNT(*) AS count FROM teacher_profiles WHERE user_id = ?' + ) + .get(users.student.id).count, + 0 + ) +}) diff --git a/server/test/realtime-classroom.test.js b/server/test/realtime-classroom.test.js new file mode 100644 index 0000000..2c6e683 --- /dev/null +++ b/server/test/realtime-classroom.test.js @@ -0,0 +1,428 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import test from 'node:test' + +import cookie from '@fastify/cookie' +import websocket from '@fastify/websocket' +import Fastify from 'fastify' + +import { createDatabase } from '../db/database.js' +import { createSession } from '../lib/session.js' +import authPlugin from '../plugins/auth.js' +import realtimeRoutes from '../routes/realtime.js' + +function readBody(response) { + return JSON.parse(response.body) +} + +function bearer(token) { + return { authorization: `Bearer ${token}` } +} + +function addUser(database, { id = randomUUID(), role, displayName }) { + database + .prepare( + `INSERT INTO users ( + id, email, password_hash, role, display_name, status + ) VALUES (?, ?, ?, ?, ?, 'active')` + ) + .run(id, `${role}-${id}@example.com`, 'x'.repeat(64), role, displayName) + + const session = createSession(database, id, { ttlSeconds: 3600 }) + return { + id, + role, + displayName, + sessionId: session.id, + token: session.token + } +} + +function addClassroom(database, studentId, teacherId, overrides = {}) { + const appointmentId = randomUUID() + const classroomId = randomUUID() + const now = Date.now() + const scheduledStart = new Date( + now + (overrides.startOffsetMinutes ?? 1) * 60_000 + ) + const scheduledEnd = new Date( + scheduledStart.getTime() + (overrides.durationMinutes ?? 60) * 60_000 + ) + + database + .prepare( + `INSERT INTO appointments ( + id, student_id, teacher_id, scheduled_start, scheduled_end, + topic, status + ) VALUES (?, ?, ?, ?, ?, ?, 'accepted')` + ) + .run( + appointmentId, + studentId, + teacherId, + scheduledStart.toISOString(), + scheduledEnd.toISOString(), + '实时课堂测试' + ) + + database + .prepare( + `INSERT INTO classrooms (id, appointment_id, room_code, status) + VALUES (?, ?, ?, 'scheduled')` + ) + .run(classroomId, appointmentId, randomUUID()) + + return classroomId +} + +function waitForEvent(socket, predicate, timeoutMs = 1500) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + socket.off('message', onMessage) + reject(new Error('Timed out waiting for WebSocket event')) + }, timeoutMs) + + function onMessage(raw) { + let event + try { + event = JSON.parse(raw.toString()) + } catch { + return + } + + if (!predicate(event)) return + clearTimeout(timer) + socket.off('message', onMessage) + resolve(event) + } + + socket.on('message', onMessage) + }) +} + +function expectNoEvent(socket, predicate, timeoutMs = 200) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + socket.off('message', onMessage) + resolve() + }, timeoutMs) + + function onMessage(raw) { + let event + try { + event = JSON.parse(raw.toString()) + } catch { + return + } + + if (!predicate(event)) return + clearTimeout(timer) + socket.off('message', onMessage) + reject(new Error(`Unexpected WebSocket event: ${event.type}`)) + } + + socket.on('message', onMessage) + }) +} + +async function createTestApp(t) { + const database = createDatabase({ filename: ':memory:' }) + const app = Fastify({ logger: false }) + const sockets = new Set() + app.decorate('db', database) + + await app.register(cookie) + await app.register(websocket) + await authPlugin(app, { + db: database, + secureCookies: false, + sessionTtlSeconds: 3600 + }) + await app.register(realtimeRoutes) + await app.ready() + + t.after(async () => { + for (const socket of sockets) socket.terminate() + await app.close() + database.close() + }) + + return { + app, + database, + async connect(path) { + const socket = await app.injectWS(path) + sockets.add(socket) + return socket + } + } +} + +async function issueTicket(app, classroomId, user) { + const response = await app.inject({ + method: 'POST', + url: `/api/v1/classrooms/${classroomId}/ticket`, + headers: bearer(user.token) + }) + return { response, body: readBody(response) } +} + +test('only classroom participants can issue tickets or read history', async (t) => { + const { app, database } = await createTestApp(t) + const student = addUser(database, { + role: 'student', + displayName: '课堂学生' + }) + const teacher = addUser(database, { + role: 'teacher', + displayName: '课堂教师' + }) + const outsider = addUser(database, { + role: 'student', + displayName: '无关学生' + }) + const classroomId = addClassroom(database, student.id, teacher.id) + + const ticket = await issueTicket(app, classroomId, outsider) + assert.equal(ticket.response.statusCode, 403) + + const history = await app.inject({ + method: 'GET', + url: `/api/v1/classrooms/${classroomId}/messages`, + headers: bearer(outsider.token) + }) + assert.equal(history.statusCode, 403) + + const participantHistory = await app.inject({ + method: 'GET', + url: `/api/v1/classrooms/${classroomId}/messages`, + headers: bearer(student.token) + }) + assert.equal(participantHistory.statusCode, 200) + assert.deepEqual(readBody(participantHistory).data.items, []) +}) + +test('participants cannot issue a ticket before the 30 minute join window', async (t) => { + const { app, database } = await createTestApp(t) + const student = addUser(database, { + role: 'student', + displayName: '远期课堂学生' + }) + const teacher = addUser(database, { + role: 'teacher', + displayName: '远期课堂教师' + }) + const classroomId = addClassroom(database, student.id, teacher.id, { + startOffsetMinutes: 120 + }) + + const issued = await issueTicket(app, classroomId, student) + assert.equal(issued.response.statusCode, 409) + assert.equal(issued.body.data.reason, 'TOO_EARLY') + assert.ok(Date.parse(issued.body.data.opensAt) > Date.now()) + assert.ok( + Date.parse(issued.body.data.closesAt) > Date.parse(issued.body.data.opensAt) + ) +}) + +test('a short-lived WebSocket ticket is consumed exactly once', async (t) => { + const { app, database, connect } = await createTestApp(t) + const student = addUser(database, { + role: 'student', + displayName: '一次性票据学生' + }) + const teacher = addUser(database, { + role: 'teacher', + displayName: '一次性票据教师' + }) + const classroomId = addClassroom(database, student.id, teacher.id) + const issued = await issueTicket(app, classroomId, student) + + assert.equal(issued.response.statusCode, 201) + assert.match(issued.body.data.ticket, /^[A-Za-z0-9_-]{40,}$/) + assert.ok(Date.parse(issued.body.data.expiresAt) > Date.now()) + + const path = `/ws/classroom?ticket=${encodeURIComponent( + issued.body.data.ticket + )}` + const socket = await connect(path) + assert.equal(socket.readyState, 1) + + await assert.rejects(app.injectWS(path), /Unexpected server response: 401/) + + database + .prepare('UPDATE sessions SET revoked_at = ? WHERE id = ?') + .run(new Date().toISOString(), student.sessionId) + const closed = once(socket, 'close') + socket.send(JSON.stringify({ type: 'ping', nonce: 'revoked-session' })) + const [closeCode] = await closed + assert.equal(closeCode, 1008) +}) + +test('two participants exchange a deduplicated message that remains in history', async (t) => { + const { app, database, connect } = await createTestApp(t) + const student = addUser(database, { + role: 'student', + displayName: '消息学生' + }) + const teacher = addUser(database, { + role: 'teacher', + displayName: '消息教师' + }) + const classroomId = addClassroom(database, student.id, teacher.id) + const teacherTicket = await issueTicket(app, classroomId, teacher) + const studentTicket = await issueTicket(app, classroomId, student) + + const teacherSocket = await connect(teacherTicket.body.data.websocketPath) + const joined = waitForEvent( + teacherSocket, + (event) => event.type === 'presence.joined' && event.user.id === student.id + ) + const studentSocket = await connect(studentTicket.body.data.websocketPath) + assert.equal((await joined).classroomId, classroomId) + + const clientMessageId = randomUUID() + const received = waitForEvent( + teacherSocket, + (event) => + event.type === 'chat.message' && + event.message.clientMessageId === clientMessageId + ) + studentSocket.send( + JSON.stringify({ + type: 'chat.message', + clientMessageId, + content: '老师好,我们开始练习吧。' + }) + ) + + const messageEvent = await received + assert.equal(messageEvent.classroomId, classroomId) + assert.equal(messageEvent.message.sender.id, student.id) + assert.equal(messageEvent.message.content, '老师好,我们开始练习吧。') + assert.equal(messageEvent.duplicate, false) + + const duplicate = waitForEvent( + studentSocket, + (event) => + event.type === 'chat.message' && + event.message.clientMessageId === clientMessageId && + event.duplicate === true + ) + studentSocket.send( + JSON.stringify({ + type: 'chat.message', + clientMessageId, + content: '客户端重试不会重复写入。' + }) + ) + assert.equal((await duplicate).message.id, messageEvent.message.id) + + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM chat_messages + WHERE classroom_id = ? AND client_message_id = ?` + ) + .get(classroomId, clientMessageId).count, + 1 + ) + + const history = await app.inject({ + method: 'GET', + url: `/api/v1/classrooms/${classroomId}/messages`, + headers: bearer(teacher.token) + }) + assert.equal(history.statusCode, 200) + assert.equal(readBody(history).data.items.length, 1) + assert.equal( + readBody(history).data.items[0].content, + '老师好,我们开始练习吧。' + ) +}) + +test('chat broadcasts and RTC signalling never cross classroom boundaries', async (t) => { + const { app, database, connect } = await createTestApp(t) + const firstStudent = addUser(database, { + role: 'student', + displayName: '第一课堂学生' + }) + const firstTeacher = addUser(database, { + role: 'teacher', + displayName: '第一课堂教师' + }) + const secondStudent = addUser(database, { + role: 'student', + displayName: '第二课堂学生' + }) + const secondTeacher = addUser(database, { + role: 'teacher', + displayName: '第二课堂教师' + }) + const firstClassroomId = addClassroom( + database, + firstStudent.id, + firstTeacher.id + ) + const secondClassroomId = addClassroom( + database, + secondStudent.id, + secondTeacher.id + ) + + const firstTicket = await issueTicket(app, firstClassroomId, firstStudent) + const secondTicket = await issueTicket(app, secondClassroomId, secondTeacher) + const firstSocket = await connect(firstTicket.body.data.websocketPath) + const secondSocket = await connect(secondTicket.body.data.websocketPath) + + const noRtcLeak = expectNoEvent( + secondSocket, + (event) => event.type === 'rtc.offer' + ) + const rejectedRtc = waitForEvent( + firstSocket, + (event) => event.type === 'error' && event.code === 'INVALID_RTC_TARGET' + ) + firstSocket.send( + JSON.stringify({ + type: 'rtc.offer', + targetUserId: secondTeacher.id, + description: { type: 'offer', sdp: 'v=0' } + }) + ) + assert.equal((await rejectedRtc).code, 'INVALID_RTC_TARGET') + await noRtcLeak + + const clientMessageId = randomUUID() + const noChatLeak = expectNoEvent( + secondSocket, + (event) => event.type === 'chat.message' + ) + firstSocket.send( + JSON.stringify({ + type: 'chat.message', + clientMessageId, + content: '只属于第一课堂的内容' + }) + ) + await noChatLeak + + assert.equal( + database + .prepare( + 'SELECT COUNT(*) AS count FROM chat_messages WHERE classroom_id = ?' + ) + .get(secondClassroomId).count, + 0 + ) + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM chat_messages + WHERE classroom_id = ? AND client_message_id = ?` + ) + .get(firstClassroomId, clientMessageId).count, + 1 + ) +}) diff --git a/server/test/teacher-verification.test.js b/server/test/teacher-verification.test.js new file mode 100644 index 0000000..ed52c48 --- /dev/null +++ b/server/test/teacher-verification.test.js @@ -0,0 +1,253 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import test from 'node:test' + +import cookie from '@fastify/cookie' +import Fastify from 'fastify' + +import { createDatabase } from '../db/database.js' +import { createSession } from '../lib/session.js' +import authPlugin from '../plugins/auth.js' +import adminRoutes from '../routes/admin.js' +import appointmentRoutes from '../routes/appointments.js' +import teacherRoutes from '../routes/teachers.js' + +function bearer(token) { + return { authorization: `Bearer ${token}` } +} + +function addUser(database, role, displayName) { + const id = randomUUID() + database + .prepare( + `INSERT INTO users ( + id, email, password_hash, role, display_name, status + ) VALUES (?, ?, ?, ?, ?, 'active')` + ) + .run(id, `${role}-${id}@example.test`, 'x'.repeat(64), role, displayName) + const session = createSession(database, id, { ttlSeconds: 3600 }) + return { id, headers: bearer(session.token) } +} + +async function createTestApp(t) { + const database = createDatabase({ filename: ':memory:' }) + const app = Fastify({ logger: false }) + app.decorate('db', database) + + await app.register(cookie) + await authPlugin(app, { + db: database, + allowBearer: true, + secureCookies: false, + sessionTtlSeconds: 3600 + }) + await app.register(teacherRoutes) + await app.register(appointmentRoutes) + await app.register(adminRoutes) + await app.ready() + + t.after(async () => { + await app.close() + database.close() + }) + + return { app, database } +} + +function futureIso(minutes) { + return new Date(Date.now() + minutes * 60_000).toISOString() +} + +test('administrator approval and revocation gate public discovery and booking', async (t) => { + const { app, database } = await createTestApp(t) + const student = addUser(database, 'student', '学习者') + const teacher = addUser(database, 'teacher', '待认证教师') + const administrator = addUser(database, 'administrator', '认证审核员') + database + .prepare( + `INSERT INTO teacher_profiles ( + user_id, school, title, certificates_json, verified_at + ) VALUES (?, ?, ?, ?, NULL)` + ) + .run( + teacher.id, + '国际中文学院', + '国际中文讲师', + JSON.stringify(['国际中文教师证书']) + ) + const courseId = randomUUID() + database + .prepare( + `INSERT INTO courses ( + id, teacher_id, title, status, published_at + ) VALUES (?, ?, ?, 'published', ?)` + ) + .run(courseId, teacher.id, '认证门禁测试课', new Date().toISOString()) + + const publicBeforeApproval = await app.inject({ + method: 'GET', + url: '/api/v1/teachers' + }) + assert.equal(publicBeforeApproval.statusCode, 200) + assert.deepEqual(publicBeforeApproval.json().data.items, []) + + const attemptedPendingDiscovery = await app.inject({ + method: 'GET', + url: '/api/v1/teachers?verified=false' + }) + assert.equal(attemptedPendingDiscovery.statusCode, 200) + assert.deepEqual(attemptedPendingDiscovery.json().data.items, []) + + const publicDetailBeforeApproval = await app.inject({ + method: 'GET', + url: `/api/v1/teachers/${teacher.id}` + }) + assert.equal(publicDetailBeforeApproval.statusCode, 404) + + const bookingBeforeApproval = await app.inject({ + method: 'POST', + url: '/api/v1/appointments', + headers: student.headers, + payload: { + teacherId: teacher.id, + courseId, + topic: '认证前预约', + scheduledStart: futureIso(120), + durationMinutes: 45 + } + }) + assert.equal(bookingBeforeApproval.statusCode, 409) + assert.equal(bookingBeforeApproval.json().data.verificationStatus, 'pending') + + const unauthorizedQueue = await app.inject({ + method: 'GET', + url: '/api/v1/admin/teacher-verifications', + headers: student.headers + }) + assert.equal(unauthorizedQueue.statusCode, 403) + + const pendingQueue = await app.inject({ + method: 'GET', + url: '/api/v1/admin/teacher-verifications', + headers: administrator.headers + }) + assert.equal(pendingQueue.statusCode, 200) + assert.equal(pendingQueue.json().data.items[0].id, teacher.id) + assert.equal(pendingQueue.json().data.items[0].verificationStatus, 'pending') + + const unauthorizedApproval = await app.inject({ + method: 'POST', + url: `/api/v1/admin/teacher-verifications/${teacher.id}`, + headers: teacher.headers, + payload: { action: 'approve' } + }) + assert.equal(unauthorizedApproval.statusCode, 403) + + const approved = await app.inject({ + method: 'POST', + url: `/api/v1/admin/teacher-verifications/${teacher.id}`, + headers: administrator.headers, + payload: { action: 'approve', note: '资料齐全,人工审核通过。' } + }) + assert.equal(approved.statusCode, 200) + assert.equal(approved.json().data.verificationStatus, 'verified') + assert.ok(approved.json().data.verifiedAt) + + const approvalAudit = database + .prepare( + `SELECT actor_id, details_json, created_at + FROM audit_logs + WHERE action = 'teacher.verification.approved' AND entity_id = ?` + ) + .get(teacher.id) + assert.equal(approvalAudit.actor_id, administrator.id) + assert.equal(JSON.parse(approvalAudit.details_json).nextStatus, 'verified') + assert.ok(approvalAudit.created_at) + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM notifications + WHERE user_id = ? AND type = 'teacher.verification.approved'` + ) + .get(teacher.id).count, + 1 + ) + + const duplicateApproval = await app.inject({ + method: 'POST', + url: `/api/v1/admin/teacher-verifications/${teacher.id}`, + headers: administrator.headers, + payload: { action: 'approve' } + }) + assert.equal(duplicateApproval.statusCode, 409) + assert.equal(duplicateApproval.json().data.currentStatus, 'verified') + + const publicAfterApproval = await app.inject({ + method: 'GET', + url: '/api/v1/teachers' + }) + assert.equal(publicAfterApproval.json().data.items[0].id, teacher.id) + const publicDetailAfterApproval = await app.inject({ + method: 'GET', + url: `/api/v1/teachers/${teacher.id}` + }) + assert.equal(publicDetailAfterApproval.statusCode, 200) + + const bookingAfterApproval = await app.inject({ + method: 'POST', + url: '/api/v1/appointments', + headers: student.headers, + payload: { + teacherId: teacher.id, + courseId, + topic: '认证后预约', + scheduledStart: futureIso(240), + durationMinutes: 45 + } + }) + assert.equal(bookingAfterApproval.statusCode, 201) + + const revokeWithoutReason = await app.inject({ + method: 'POST', + url: `/api/v1/admin/teacher-verifications/${teacher.id}`, + headers: administrator.headers, + payload: { action: 'revoke' } + }) + assert.equal(revokeWithoutReason.statusCode, 400) + + const revoked = await app.inject({ + method: 'POST', + url: `/api/v1/admin/teacher-verifications/${teacher.id}`, + headers: administrator.headers, + payload: { action: 'revoke', note: '证书信息需要重新核验。' } + }) + assert.equal(revoked.statusCode, 200) + assert.equal(revoked.json().data.verificationStatus, 'pending') + assert.equal(revoked.json().data.verifiedAt, null) + + const publicAfterRevocation = await app.inject({ + method: 'GET', + url: `/api/v1/teachers/${teacher.id}` + }) + assert.equal(publicAfterRevocation.statusCode, 404) + + const revokedAudit = database + .prepare( + `SELECT actor_id, details_json, created_at + FROM audit_logs + WHERE action = 'teacher.verification.revoked' AND entity_id = ?` + ) + .get(teacher.id) + assert.equal(revokedAudit.actor_id, administrator.id) + assert.equal(JSON.parse(revokedAudit.details_json).nextStatus, 'pending') + assert.ok(revokedAudit.created_at) + assert.equal( + database + .prepare( + `SELECT COUNT(*) AS count FROM notifications + WHERE user_id = ? AND type = 'teacher.verification.revoked'` + ) + .get(teacher.id).count, + 1 + ) +}) diff --git a/src/api/common.js b/src/api/common.js deleted file mode 100644 index d3f2a34..0000000 --- a/src/api/common.js +++ /dev/null @@ -1,16 +0,0 @@ -import request from '@/utils/request' - -//通用的获取房间号码接口 -export const getSenderId = (token, cursor, appointmentCourseId) => { - return request({ - url: `/chat/msg/appointCoursePage?token=${token}`, - method: 'POST', - data: { - data: { - pageSize: 10, - cursor, - appointmentCourseId - } - } - }) -} diff --git a/src/api/platform.js b/src/api/platform.js new file mode 100644 index 0000000..f4bc2bf --- /dev/null +++ b/src/api/platform.js @@ -0,0 +1,207 @@ +import request from '@/utils/request' + +async function dataRequest(config) { + const response = await request(config) + return response.data?.data +} + +export const getCourses = (params = {}) => + dataRequest({ method: 'GET', url: '/courses', params }) + +export const getCourse = (courseId) => + dataRequest({ method: 'GET', url: `/courses/${courseId}` }) + +export const createCourse = (payload) => + dataRequest({ method: 'POST', url: '/courses', data: payload }) + +export const updateCourse = (courseId, payload) => + dataRequest({ method: 'PATCH', url: `/courses/${courseId}`, data: payload }) + +export const submitCourse = (courseId) => + dataRequest({ method: 'POST', url: `/courses/${courseId}/submit` }) + +export const getCourseReviewQueue = (params = {}) => + dataRequest({ method: 'GET', url: '/admin/course-reviews', params }) + +export const reviewCourse = (courseId, payload) => + dataRequest({ + method: 'POST', + url: `/admin/course-reviews/${courseId}`, + data: payload + }) + +export const getTeacherVerificationQueue = (params = {}) => + dataRequest({ + method: 'GET', + url: '/admin/teacher-verifications', + params + }) + +export const decideTeacherVerification = (teacherId, payload) => + dataRequest({ + method: 'POST', + url: `/admin/teacher-verifications/${teacherId}`, + data: payload + }) + +export const getAdminMetrics = () => + dataRequest({ method: 'GET', url: '/admin/metrics' }) + +export const getTeachers = (params = {}) => + dataRequest({ method: 'GET', url: '/teachers', params }) + +export const getTeacher = (teacherId) => + dataRequest({ method: 'GET', url: `/teachers/${teacherId}` }) + +export const createAppointment = (payload) => + dataRequest({ method: 'POST', url: '/appointments', data: payload }) + +export const getAppointments = (params = {}) => + dataRequest({ method: 'GET', url: '/appointments', params }) + +export const respondToAppointment = (appointmentId, payload) => + dataRequest({ + method: 'PATCH', + url: `/appointments/${appointmentId}/respond`, + data: payload + }) + +export const cancelAppointment = (appointmentId) => + dataRequest({ + method: 'PATCH', + url: `/appointments/${appointmentId}/cancel` + }) + +export const getClassroomJoinInfo = (classroomId) => + dataRequest({ + method: 'GET', + url: `/classrooms/${classroomId}/join-info` + }) + +export const createClassroomTicket = (classroomId) => + dataRequest({ + method: 'POST', + url: `/classrooms/${classroomId}/ticket` + }) + +export const getClassroomMessages = (classroomId, params = {}) => + dataRequest({ + method: 'GET', + url: `/classrooms/${classroomId}/messages`, + params + }) + +export const completeClassroom = (classroomId) => + dataRequest({ + method: 'POST', + url: `/classrooms/${classroomId}/complete` + }) + +export const getCourseAssignments = (courseId) => + dataRequest({ method: 'GET', url: `/courses/${courseId}/assignments` }) + +export const createAssignment = (courseId, payload) => + dataRequest({ + method: 'POST', + url: `/courses/${courseId}/assignments`, + data: payload + }) + +export const updateAssignment = (assignmentId, payload) => + dataRequest({ + method: 'PATCH', + url: `/assignments/${assignmentId}`, + data: payload + }) + +export const publishAssignment = (assignmentId) => + dataRequest({ + method: 'POST', + url: `/assignments/${assignmentId}/publish` + }) + +export const closeAssignment = (assignmentId) => + dataRequest({ + method: 'POST', + url: `/assignments/${assignmentId}/close` + }) + +export const getAssignment = (assignmentId) => + dataRequest({ method: 'GET', url: `/assignments/${assignmentId}` }) + +export const saveSubmission = (assignmentId, payload) => + dataRequest({ + method: 'POST', + url: `/assignments/${assignmentId}/submissions`, + data: payload + }) + +export const getMySubmissions = (params = {}) => + dataRequest({ method: 'GET', url: '/me/submissions', params }) + +export const getAssignmentSubmissions = (assignmentId, params = {}) => + dataRequest({ + method: 'GET', + url: `/assignments/${assignmentId}/submissions`, + params + }) + +export const gradeSubmission = (submissionId, payload) => + dataRequest({ + method: 'PATCH', + url: `/submissions/${submissionId}/grade`, + data: payload + }) + +export const getNotifications = (params = {}) => + dataRequest({ method: 'GET', url: '/notifications', params }) + +export const readNotification = (notificationId) => + dataRequest({ + method: 'PATCH', + url: `/notifications/${notificationId}/read` + }) + +export const readAllNotifications = () => + dataRequest({ method: 'POST', url: '/notifications/read-all' }) + +export async function uploadFile(file, category, onUploadProgress) { + const formData = new FormData() + formData.append('file', file) + return dataRequest({ + method: 'POST', + url: '/files', + params: { category }, + data: formData, + onUploadProgress + }) +} + +export const deleteFile = (fileId) => + dataRequest({ method: 'DELETE', url: `/files/${fileId}` }) + +export const getDialogues = (params = {}) => + dataRequest({ method: 'GET', url: '/dialogues', params }) + +export const createDialogue = (payload) => + dataRequest({ method: 'POST', url: '/dialogues', data: payload }) + +export const getDialogue = (dialogueId) => + dataRequest({ method: 'GET', url: `/dialogues/${dialogueId}` }) + +export const sendDialogueMessage = (dialogueId, message) => + dataRequest({ + method: 'POST', + url: `/dialogues/${dialogueId}/messages`, + data: { message } + }) + +export const getProfile = () => dataRequest({ method: 'GET', url: '/me' }) + +export const updateProfile = (payload) => + dataRequest({ method: 'PATCH', url: '/me', data: payload }) + +export const updatePassword = (payload) => + dataRequest({ method: 'PATCH', url: '/me/password', data: payload }) + +export { dataRequest } diff --git a/src/api/student.js b/src/api/student.js deleted file mode 100644 index 9a0f9da..0000000 --- a/src/api/student.js +++ /dev/null @@ -1,163 +0,0 @@ -import request from '@/utils/request' -import { FORUM_API_URL } from '@/config/runtime' - -//学生用户登录 -export const studentLogin = (email, password) => { - return request({ - url: '/student/login', - method: 'POST', - data: { - data: { - email, - password - } - } - }) -} - -//学生用户注册 -export const studentRegister = (email, verificationCode, password) => { - return request({ - url: '/student/register', - method: 'POST', - data: { - data: { - email, - verificationCode, - password - } - } - }) -} - -//获取网课课程列表 -export const getOnlineCoursesList = ( - token, - currentPage, - pageSize, - courseCategory, - title -) => { - return request({ - url: '/student/getOnlineCourseList', - headers: { token }, - method: 'POST', - data: { - data: { - currentPage, - pageSize, - courseCategory, - title - } - } - }) -} - -//获取网络课程详情页 -export const getOnlineCoursesDetail = (token, onlineCourseId) => { - return request({ - url: `/student/getOnlineCourseDetail/${onlineCourseId}`, - headers: { token }, - method: 'GET' - }) -} - -//修改个人信息 -export const updateStudentInfo = (name, country, region, age, level) => { - return request({ - url: '/student/update', - method: 'POST', - data: { - data: { - name, - country, - region, - age, - level - } - } - }) -} - -//获取学生作业 -export const getStudentHomework = (courseType, courseId, token) => { - return request({ - url: `/student/homework/${courseType}/${courseId}`, - headers: { token }, - method: 'GET' - }) -} - -//提交作业 -export const submitHomework = (courseType, courseId, token, homework) => { - return request({ - url: `/student/homework/${courseType}/${courseId}`, - headers: { token }, - method: 'POST', - data: { - data: { - homework - } - } - }) -} - -//获取老师列表 -export const getTeacherList = ( - token, - currentPage, - minScore, - minExperience, - expertise -) => { - return request({ - url: '/student/teacherList', - headers: { token }, - method: 'POST', - data: { - data: { - currentPage, - pageSize: 10, - minScore, - minExperience, - expertise - } - } - }) -} - -//搜索老师 -export const searchTeacher = (token, currentPage, teacherName) => { - return request({ - url: '/student/teacherName', - headers: { token }, - method: 'POST', - data: { - data: { - currentPage, - pageSize: 10, - teacherName - } - } - }) -} - -//预约老师 -export const bookTeacher = (token, teacherId) => { - return request({ - url: `/student/book/${teacherId}`, - headers: { token }, - method: 'POST' - }) -} - -//获取话论 -export const getForum = (words) => { - return request({ - url: FORUM_API_URL, - method: 'POST', - data: { - words - } - }) -} diff --git a/src/api/universal.js b/src/api/universal.js index 1378d07..5871244 100644 --- a/src/api/universal.js +++ b/src/api/universal.js @@ -1,15 +1,13 @@ import request from '@/utils/request' -//[C001C]注册邮箱发送邮箱验证码 -export const sendEmail = (email) => { +// 注册邮箱验证码;本地开发环境会返回 developmentCode 方便演示。 +export const sendEmail = (email, role = 'student') => { return request({ - url: '/common/sendRegisterEmailCode', + url: '/auth/verification-code', method: 'POST', data: { - data: { - // 关键修复:按照API要求嵌套在data对象中 - email - } + email, + role } }) } diff --git a/src/api/user.js b/src/api/user.js index ef1d581..b3e0049 100644 --- a/src/api/user.js +++ b/src/api/user.js @@ -1,25 +1,35 @@ import request from '@/utils/request' -const loginEndpoints = { - teacher: '/teacher/login', - administrator: '/administrator/login' -} - export const loginByRole = (role, email, password) => { - const url = loginEndpoints[role] - - if (!url) { - throw new Error(`Unsupported login role: ${role}`) - } - return request({ - url, + url: '/auth/login', method: 'POST', data: { - data: { - email, - password - } + email, + password, + role } }) } + +export const registerByRole = (registration) => { + return request({ + url: '/auth/register', + method: 'POST', + data: registration + }) +} + +export const getSession = () => { + return request({ + url: '/auth/session', + method: 'GET' + }) +} + +export const logoutSession = () => { + return request({ + url: '/auth/logout', + method: 'POST' + }) +} diff --git a/src/components/account/NotificationCenter.vue b/src/components/account/NotificationCenter.vue new file mode 100644 index 0000000..fefaf74 --- /dev/null +++ b/src/components/account/NotificationCenter.vue @@ -0,0 +1,347 @@ + + + + + diff --git a/src/components/account/PasswordForm.vue b/src/components/account/PasswordForm.vue new file mode 100644 index 0000000..771715e --- /dev/null +++ b/src/components/account/PasswordForm.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/src/components/account/ProfileForm.vue b/src/components/account/ProfileForm.vue new file mode 100644 index 0000000..8d612f6 --- /dev/null +++ b/src/components/account/ProfileForm.vue @@ -0,0 +1,550 @@ + + + + + diff --git a/src/components/basic/tabSwitch.vue b/src/components/basic/tabSwitch.vue index cf77126..b68ed83 100644 --- a/src/components/basic/tabSwitch.vue +++ b/src/components/basic/tabSwitch.vue @@ -73,11 +73,16 @@ nextTick(() => { const showModal = ref(false) //模态框确认事件 -const handleExit = () => { - userStore.clearSession() - studentStore.clearUserInfo() - showModal.value = false - router.replace('/login') +const handleExit = async () => { + try { + await userStore.logout() + } catch { + // 即使服务端暂时不可达,也必须清除本地资料并回到登录页。 + } finally { + studentStore.clearUserInfo() + showModal.value = false + await router.replace('/login') + } }