From 29068c3b1e6921411229c059f8de595ad74c6b30 Mon Sep 17 00:00:00 2001 From: Henrythefoodie <13022037121@163.com> Date: Sun, 19 Jul 2026 22:08:21 +0800 Subject: [PATCH 01/20] docs: define full-stack completion architecture --- .../0001-self-contained-modular-monolith.md | 18 +++ docs/adr/0002-server-side-opaque-sessions.md | 20 +++ ...2026-07-19-full-stack-completion-design.md | 102 +++++++++++++ .../plans/2026-07-19-full-stack-completion.md | 139 ++++++++++++++++++ 4 files changed, 279 insertions(+) create mode 100644 docs/adr/0001-self-contained-modular-monolith.md create mode 100644 docs/adr/0002-server-side-opaque-sessions.md create mode 100644 docs/plans/2026-07-19-full-stack-completion-design.md create mode 100644 docs/plans/2026-07-19-full-stack-completion.md 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/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..2956a23 --- /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`、`/uploads` 和 `/ws` 代理到 API;生产环境由 API 同源托管 `dist/`。外部 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. From 6c0048ea2c155f8f2e70be55fb7a7ed0a4a022e5 Mon Sep 17 00:00:00 2001 From: Henrythefoodie <13022037121@163.com> Date: Sun, 19 Jul 2026 22:18:56 +0800 Subject: [PATCH 02/20] feat(server): add persistent API and secure auth --- .env.example | 22 +- .gitignore | 7 + package.json | 39 +- pnpm-lock.yaml | 7060 ++++++++++++++++++++-------------- pnpm-workspace.yaml | 5 + server/app.js | 183 + server/config.js | 76 + server/db/database.js | 79 + server/db/migrate.js | 25 + server/db/reset.js | 48 + server/db/schema.sql | 274 ++ server/db/seed-data.js | 500 +++ server/db/seed.js | 39 + server/index.js | 51 + server/lib/http.js | 91 + server/lib/password.js | 139 + server/lib/session.js | 241 ++ server/plugins/auth.js | 116 + server/routes/auth.js | 280 ++ server/routes/profile.js | 249 ++ server/test/auth.test.js | 327 ++ server/test/database.test.js | 176 + server/test/health.test.js | 202 + src/router/index.js | 12 +- vite.config.js | 8 + 25 files changed, 7286 insertions(+), 2963 deletions(-) create mode 100644 pnpm-workspace.yaml create mode 100644 server/app.js create mode 100644 server/config.js create mode 100644 server/db/database.js create mode 100644 server/db/migrate.js create mode 100644 server/db/reset.js create mode 100644 server/db/schema.sql create mode 100644 server/db/seed-data.js create mode 100644 server/db/seed.js create mode 100644 server/index.js create mode 100644 server/lib/http.js create mode 100644 server/lib/password.js create mode 100644 server/lib/session.js create mode 100644 server/plugins/auth.js create mode 100644 server/routes/auth.js create mode 100644 server/routes/profile.js create mode 100644 server/test/auth.test.js create mode 100644 server/test/database.test.js create mode 100644 server/test/health.test.js diff --git a/.env.example b/.env.example index 8f41eef..ff42131 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,18 @@ -# REST API origin. Do not include a trailing slash. -VITE_API_BASE_URL=http://localhost:7777 +# The browser uses Vite's same-origin proxy by default. Set only for a split deployment. +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 +APP_ORIGIN=http://localhost:5173 +SESSION_TTL_HOURS=12 -# WebSocket endpoint used by chat and live-class signaling. -VITE_WEBSOCKET_URL=ws://localhost:7788/websocket +# Optional external adapters. Core demo flows work without these values. +SMTP_URL= +AI_API_URL= +AI_API_KEY= +TURN_URL= +TURN_USERNAME= +TURN_CREDENTIAL= diff --git a/.gitignore b/.gitignore index 8ee54e8..8b00968 100644 --- a/.gitignore +++ b/.gitignore @@ -8,11 +8,18 @@ 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/ /cypress/videos/ /cypress/screenshots/ diff --git a/package.json b/package.json index cd0fe07..feda02e 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,49 @@ "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 --watch server/index.js", + "start": "node server/index.js", "build": "vite build", "preview": "vite preview", + "db:migrate": "node server/db/migrate.js", + "db:seed": "node server/db/seed.js", + "db:reset": "node server/db/reset.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/", + "format:check": "prettier --check src/ server/ docs/", + "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", "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-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..65f2c9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,2027 +1,1384 @@ -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 +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 + 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.0.0-rc.1 + version: 11.0.0-rc.1(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} - engines: {node: '>=12.22'} - dev: true + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} - /@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} - engines: {node: '>=18.18'} - dev: true + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} - /@humanwhocodes/retry@0.4.3: - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==, tarball: https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz} + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - dev: true - /@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} + '@intlify/core-base@11.0.0-rc.1': + resolution: {integrity: sha512-fnfZoa9pb1dKM3L1UkDUGLLrPFQ2BK98x4/fMqwS/fktUor34vQR/itPtfv652ZTplenXXLCEYjUYTGfDZgMTQ==} engines: {node: '>= 16'} - dependencies: - '@intlify/message-compiler': 11.0.0-rc.1 - '@intlify/shared': 11.0.0-rc.1 - dev: false - /@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} + '@intlify/message-compiler@11.0.0-rc.1': + resolution: {integrity: sha512-TGw2uBfuTFTegZf/BHtUQBEKxl7Q/dVGLoqRIdw8lFsp9g/53sYn5iD+0HxIzdYjbWL6BTJMXCPUHp9PxDTRPw==} engines: {node: '>= 16'} - dependencies: - '@intlify/shared': 11.0.0-rc.1 - source-map-js: 1.2.1 - dev: false - /@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} + '@intlify/shared@11.0.0-rc.1': + resolution: {integrity: sha512-8tR1xe7ZEbkabTuE/tNhzpolygUn9OaYp9yuYAF4MgDNZg06C3Qny80bes2/e9/Wm3aVkPUlCw6WgU7mQd0yEg==} engines: {node: '>= 16'} - dev: false - /@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} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.12': + resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} + + '@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} - /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 + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - /binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==, tarball: https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz} + baseline-browser-mapping@2.10.43: + resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} + engines: {node: '>=6.0.0'} + hasBin: 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==} 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} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} 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} - 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 +1390,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 +1426,698 @@ 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==} - /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} + fastify@5.10.0: + resolution: {integrity: sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==} + + 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 - /globals@13.24.0: - resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==, tarball: https://registry.npmmirror.com/globals/-/globals-13.24.0.tgz} + 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==} 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==} + + ini@1.3.0: + resolution: {integrity: sha512-6tyfJkFAmQV64x9Li007PNZgNxqRywig4Rv8PDfzU9kgDBmURaIvjSL2wKyx45LPSIXsvDkFGbOMqMHC1PsORA==} + deprecated: Please update to ini >=1.3.6 to avoid a prototype pollution issue - /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 + 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==, tarball: https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz} + 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-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} - /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-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - /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@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - /json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==, tarball: https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz} + 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'} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} - /merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==, tarball: https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz} + 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'} - /mrmime@2.0.1: - resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==, tarball: https://registry.npmmirror.com/mrmime/-/mrmime-2.0.1.tgz} + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + 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 - /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 + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - /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 + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - /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-abi@3.94.0: + resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} + engines: {node: '>=10'} - /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-addon-api@7.0.0: + resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==} - /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 + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} - /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'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} - /pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, tarball: https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - /perfect-debounce@1.0.0: - resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==, tarball: https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} - /picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, tarball: https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz} + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} - /picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==, tarball: https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz} + 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==} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - /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} + 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 +2127,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'} - /superjson@2.2.2: - resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==, tarball: https://registry.npmmirror.com/superjson/-/superjson-2.2.2.tgz} + 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.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==} - /tinyglobby@0.2.13: - resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==, tarball: https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.13.tgz} + 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.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==} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - /type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==, tarball: https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz} + 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.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} - /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 - - /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} + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} 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} - 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 + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - /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 - - /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 +2512,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 +2563,2650 @@ 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} + vue-i18n@11.0.0-rc.1: + resolution: {integrity: sha512-qbdCbA537HEdr2yXQ4ec/OMDsoHjod1DwnWbrf+l4Cu/O7CYTCKsOyITUm3RmrCJgRnoVycuR6i/JWdNTJvD5g==} engines: {node: '>= 16'} 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} - /yoctocolors@2.1.1: - resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==, tarball: https://registry.npmmirror.com/yoctocolors/-/yoctocolors-2.1.1.tgz} + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + 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: - zod: 3.24.4 - dev: true + '@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: + '@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.0.0-rc.1': + dependencies: + '@intlify/message-compiler': 11.0.0-rc.1 + '@intlify/shared': 11.0.0-rc.1 + + '@intlify/message-compiler@11.0.0-rc.1': + dependencies: + '@intlify/shared': 11.0.0-rc.1 + source-map-js: 1.2.1 + + '@intlify/shared@11.0.0-rc.1': {} + + '@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.0: {} + + 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: {} + + 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.0 + 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.0.0-rc.1(vue@3.5.40): + dependencies: + '@intlify/core-base': 11.0.0-rc.1 + '@intlify/shared': 11.0.0-rc.1 + '@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..0828b88 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +allowBuilds: + '@parcel/watcher': true + better-sqlite3: true + esbuild: true + vue-demi: true diff --git a/server/app.js b/server/app.js new file mode 100644 index 0000000..b28b768 --- /dev/null +++ b/server/app.js @@ -0,0 +1,183 @@ +/* global process */ + +import { existsSync } from 'node:fs' + +import cookie from '@fastify/cookie' +import helmet from '@fastify/helmet' +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 authRoutes from './routes/auth.js' +import profileRoutes from './routes/profile.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 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 + }) + await app.register(websocket) + + if (database) { + await authPlugin(app, { + db: database, + allowBearer: config.allowBearer, + secureCookies: config.secureCookies, + sessionTtlSeconds: config.sessionTtlSeconds + }) + await app.register(authRoutes) + await app.register(profileRoutes) + } + + 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..0a04db7 --- /dev/null +++ b/server/config.js @@ -0,0 +1,76 @@ +/* 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 parsed = + value === undefined || value === '' ? fallback : Number.parseInt(value, 10) + + 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) { + if (value === undefined || value === '') return fallback + return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase()) +} + +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' + const sessionTtlHours = integer( + env.SESSION_TTL_HOURS, + 12, + 'SESSION_TTL_HOURS', + { + max: 24 * 365 + } + ) + + return Object.freeze({ + nodeEnv, + isProduction: nodeEnv === 'production', + host: env.HOST || '127.0.0.1', + port: integer(env.PORT, 7777, 'PORT', { max: 65_535 }), + appOrigin: env.APP_ORIGIN || 'http://localhost:5173', + databasePath: localPath(env.DATABASE_PATH, '.data/platform.db'), + uploadDir: localPath(env.UPLOAD_DIR, '.data/uploads'), + 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, + secureCookies: boolean(env.SECURE_COOKIES, nodeEnv === 'production'), + allowBearer: boolean(env.ALLOW_BEARER_AUTH, true), + trustProxy: boolean(env.TRUST_PROXY, false), + logger: boolean(env.LOGGER, nodeEnv !== 'test'), + seedOnStart: boolean(env.SEED_ON_START, nodeEnv !== 'production'), + smtpUrl: env.SMTP_URL || '', + aiApiUrl: env.AI_API_URL || '', + 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/database.js b/server/db/database.js new file mode 100644 index 0000000..2e97c48 --- /dev/null +++ b/server/db/database.js @@ -0,0 +1,79 @@ +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') +const initialMigration = '001_initial_schema' + +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 migrate = database.transaction(() => { + // The schema is intentionally idempotent. Executing it on every startup makes + // newly added indexes available to existing development databases, while the + // migration ledger still records which baseline was installed. + database.exec(schemaSql) + database + .prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)') + .run(initialMigration) + }) + + migrate() + 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..e19e98b --- /dev/null +++ b/server/db/schema.sql @@ -0,0 +1,274 @@ +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 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 INDEX IF NOT EXISTS users_role_status_idx + ON users (role, status); + +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); diff --git a/server/db/seed-data.js b/server/db/seed-data.js new file mode 100644 index 0000000..1285c99 --- /dev/null +++ b/server/db/seed-data.js @@ -0,0 +1,500 @@ +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 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) + 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..69572a3 --- /dev/null +++ b/server/plugins/auth.js @@ -0,0 +1,116 @@ +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 secureCookies = + options.secureCookies ?? process.env.NODE_ENV === 'production' + + if (!app.hasRequestDecorator('auth')) { + app.decorateRequest('auth', null) + } + + 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/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..68de600 --- /dev/null +++ b/server/routes/auth.js @@ -0,0 +1,280 @@ +import { randomUUID } from 'node:crypto' + +import { z } from 'zod' + +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(), + verificationCode: z.string().trim().max(20).optional() +}) + +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 + } +} + +export async function authRoutes(app) { + const db = app.db + if (!db) { + throw new Error('authRoutes requires app.db') + } + + 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 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 + ) + + return createSession(db, userId, { + ttlSeconds: app.sessionTtlSeconds, + ...sessionMetadata(request) + }) + }) + + session = createUserAndSession() + } catch (error) { + 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/profile.js b/server/routes/profile.js new file mode 100644 index 0000000..5253309 --- /dev/null +++ b/server/routes/profile.js @@ -0,0 +1,249 @@ +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 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() + }) + .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' +} + +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) => responseData(reply, request.auth.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) + } + + 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) + + db.prepare(`UPDATE users SET ${assignments.join(', ')} WHERE id = ?`).run( + ...values + ) + + const user = db + .prepare('SELECT * FROM users WHERE id = ? LIMIT 1') + .get(request.auth.user.id) + + return responseData(reply, publicUser(user), '个人资料已更新') + } + ) + + 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, publicUser(updatedUser), '密码已更新') + } + ) +} + +export default profileRoutes diff --git a/server/test/auth.test.js b/server/test/auth.test.js new file mode 100644 index 0000000..900b7c4 --- /dev/null +++ b/server/test/auth.test.js @@ -0,0 +1,327 @@ +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, { + 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 = {}) { + return app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: 'student@example.com', + password: 'Secure123!', + role: 'student', + displayName: '测试学生', + ...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('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 register(app) + assert.equal(duplicate.statusCode, 409) + assert.equal(readBody(duplicate).code, 409) + + const administrator = await register(app, { + 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) +}) diff --git a/server/test/database.test.js b/server/test/database.test.js new file mode 100644 index 0000000..d91d27e --- /dev/null +++ b/server/test/database.test.js @@ -0,0 +1,176 @@ +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 { createDatabase, migrateDatabase } from '../db/database.js' +import { + DEMO_ACCOUNTS, + DEMO_PASSWORD, + seedDatabase, + verifyPasswordHash +} from '../db/seed.js' + +const requiredTables = [ + 'users', + 'sessions', + 'verification_codes', + 'courses', + 'course_reviews', + 'appointments', + 'classrooms', + 'assignments', + 'assignment_questions', + 'submissions', + 'chat_messages', + 'notifications', + 'audit_logs' +] + +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.equal( + database + .prepare( + "SELECT COUNT(*) AS count FROM schema_migrations WHERE id = '001_initial_schema'" + ) + .get().count, + 1 + ) + + migrateDatabase(database) + assert.equal( + database.prepare('SELECT COUNT(*) AS count FROM schema_migrations').get() + .count, + 1, + 'migrations should be repeatable' + ) +}) + +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/health.test.js b/server/test/health.test.js new file mode 100644 index 0000000..10b36f8 --- /dev/null +++ b/server/test/health.test.js @@ -0,0 +1,202 @@ +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' } + }) + 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
') +}) diff --git a/src/router/index.js b/src/router/index.js index 31f8572..710f221 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -159,9 +159,7 @@ const router = createRouter({ path: 'teachingDocking', name: 'teachingDocking', component: () => - import( - '@/views/teacher/TeachingDockingPage/teachingDockingPage.vue' - ) + import('@/views/teacher/TeachingDockingPage/teachingDockingPage.vue') }, // 网络课程模块 { @@ -196,9 +194,7 @@ const router = createRouter({ { path: '/administrator', component: () => - import( - '@/views/administrator/administratorLayout/administratorLayout.vue' - ), + import('@/views/administrator/administratorLayout/administratorLayout.vue'), meta: { requiresAuth: true, role: 'administrator' }, redirect: '/administrator/courseDocking', // 重定向到首页 children: [ @@ -238,9 +234,7 @@ const router = createRouter({ path: 'changePasswordAdmin', name: 'changePasswordAdmin', component: () => - import( - '@/views/administrator/personalCenter/changePassword.vue' - ) + import('@/views/administrator/personalCenter/changePassword.vue') }, { // 消息通知模块 diff --git a/vite.config.js b/vite.config.js index 72dc5eb..764e388 100644 --- a/vite.config.js +++ b/vite.config.js @@ -12,6 +12,14 @@ export default defineConfig({ } }, server: { + proxy: { + '/api': 'http://127.0.0.1:7777', + '/uploads': 'http://127.0.0.1:7777', + '/ws': { + target: 'ws://127.0.0.1:7777', + ws: true + } + }, hmr: { overlay: false } From de8f14236d9cea12967fdd7eb208e70bdb0b78b8 Mon Sep 17 00:00:00 2001 From: Henrythefoodie <13022037121@163.com> Date: Sun, 19 Jul 2026 22:42:24 +0800 Subject: [PATCH 03/20] feat(domain): add course review and booking workflows --- .env.example | 1 + server/app.js | 9 + server/config.js | 1 + server/db/schema.sql | 57 ++ server/db/seed-data.js | 29 + server/plugins/auth.js | 13 + server/routes/admin.js | 453 +++++++++++++++ server/routes/appointments.js | 684 +++++++++++++++++++++++ server/routes/auth.js | 181 +++++- server/routes/courses.js | 524 +++++++++++++++++ server/routes/teachers.js | 234 ++++++++ server/test/appointment-workflow.test.js | 433 ++++++++++++++ server/test/auth.test.js | 55 +- server/test/course-workflow.test.js | 324 +++++++++++ server/test/database.test.js | 6 +- 15 files changed, 2995 insertions(+), 9 deletions(-) create mode 100644 server/routes/admin.js create mode 100644 server/routes/appointments.js create mode 100644 server/routes/courses.js create mode 100644 server/routes/teachers.js create mode 100644 server/test/appointment-workflow.test.js create mode 100644 server/test/course-workflow.test.js diff --git a/.env.example b/.env.example index ff42131..e0ab231 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,7 @@ DATABASE_PATH=.data/platform.db UPLOAD_DIR=.data/uploads APP_ORIGIN=http://localhost:5173 SESSION_TTL_HOURS=12 +VERIFICATION_CODE_SECRET= # Optional external adapters. Core demo flows work without these values. SMTP_URL= diff --git a/server/app.js b/server/app.js index b28b768..ea1dd38 100644 --- a/server/app.js +++ b/server/app.js @@ -12,8 +12,12 @@ 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 authRoutes from './routes/auth.js' +import appointmentRoutes from './routes/appointments.js' +import courseRoutes from './routes/courses.js' import profileRoutes from './routes/profile.js' +import teacherRoutes from './routes/teachers.js' function resolveConfig(overrides = {}) { const nodeEnv = overrides.nodeEnv || defaultConfig.nodeEnv @@ -87,11 +91,16 @@ export async function buildApp({ 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(adminRoutes) + await app.register(teacherRoutes) + await app.register(appointmentRoutes) } app.get('/api/v1/health', async (_request, reply) => diff --git a/server/config.js b/server/config.js index 0a04db7..a1e3c75 100644 --- a/server/config.js +++ b/server/config.js @@ -57,6 +57,7 @@ export function loadConfig(env = process.env) { rateLimitWindow: env.RATE_LIMIT_WINDOW || '1 minute', sessionTtlHours, sessionTtlSeconds: sessionTtlHours * 60 * 60, + verificationCodeSecret: env.VERIFICATION_CODE_SECRET || '', secureCookies: boolean(env.SECURE_COOKIES, nodeEnv === 'production'), allowBearer: boolean(env.ALLOW_BEARER_AUTH, true), trustProxy: boolean(env.TRUST_PROXY, false), diff --git a/server/db/schema.sql b/server/db/schema.sql index e19e98b..e4c6937 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -16,6 +16,22 @@ CREATE TABLE IF NOT EXISTS users ( 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, @@ -201,9 +217,44 @@ CREATE TABLE IF NOT EXISTS audit_logs ( 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; @@ -272,3 +323,9 @@ CREATE INDEX IF NOT EXISTS audit_logs_actor_created_idx 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 index 1285c99..9ec269f 100644 --- a/server/db/seed-data.js +++ b/server/db/seed-data.js @@ -246,6 +246,34 @@ function insertCourses(database, userIds) { ) } +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 ( @@ -475,6 +503,7 @@ export function seedDatabase(database) { const seed = database.transaction(() => { const userIds = insertUsers(database) + insertTeacherProfile(database, userIds) insertCourses(database, userIds) insertAppointments(database, userIds) insertAssignments(database, userIds) diff --git a/server/plugins/auth.js b/server/plugins/auth.js index 69572a3..426f25f 100644 --- a/server/plugins/auth.js +++ b/server/plugins/auth.js @@ -24,6 +24,7 @@ export async function authPlugin(app, options = {}) { 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' @@ -31,6 +32,18 @@ export async function authPlugin(app, options = {}) { 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) { diff --git a/server/routes/admin.js b/server/routes/admin.js new file mode 100644 index 0000000..ef63da8 --- /dev/null +++ b/server/routes/admin.js @@ -0,0 +1,453 @@ +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 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 {} + } +} + +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 * FROM courses WHERE 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 + } + + 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_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/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..fb10a7b --- /dev/null +++ b/server/routes/appointments.js @@ -0,0 +1,684 @@ +import { randomBytes, randomUUID } from 'node:crypto' + +import { z } from 'zod' + +const identifierSchema = z.string().trim().min(1).max(128) +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 ?? '') + ) +} + +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 id FROM users + WHERE id = ? AND role = 'teacher' AND status = 'active' + LIMIT 1` + ) + .get(result.data.teacherId) + if (!teacher) { + return responseError(reply, 404, '教师不存在或当前不可预约') + } + + 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() + + try { + 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 + ) + } 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 conflict = db + .prepare( + `SELECT id FROM appointments + WHERE teacher_id = ? + AND status = 'accepted' + AND id <> ? + AND scheduled_start < ? + AND scheduled_end > ? + ORDER BY scheduled_start ASC + LIMIT 1` + ) + .get( + appointment.teacher_id, + appointment.id, + appointment.scheduled_end, + appointment.scheduled_start + ) + if (conflict) { + return { + error: 'SCHEDULE_CONFLICT', + conflictingAppointmentId: conflict.id + } + } + } + + 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: `/classrooms/${classroomId}`, + dedupeKey: `appointment:${appointment.id}:accepted:${appointment.student_id}` + }) + insertNotification(db, { + userId: appointment.teacher_id, + type: 'appointment_accepted', + title: '课堂预约已确认', + body: '课堂已进入排期,并已创建专属教室。', + appointmentId: appointment.id, + link: `/classrooms/${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 === 'SCHEDULE_CONFLICT') { + return responseError(reply, 409, '该时间段与已接受的课堂冲突', { + conflictingAppointmentId: outcome.conflictingAppointmentId + }) + } + + 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.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.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, '课堂当前不可加入') + } + + return responseData(reply, { + classroomId: classroom.id, + roomCode: classroom.room_code, + 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/auth.js b/server/routes/auth.js index 68de600..8b6c494 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -1,4 +1,5 @@ -import { randomUUID } from 'node:crypto' +import { createHmac, randomInt, randomUUID, timingSafeEqual } from 'node:crypto' +import { Buffer } from 'node:buffer' import { z } from 'zod' @@ -50,7 +51,22 @@ const registerSchema = z.object({ .nullable() .optional(), bio: z.string().trim().max(500).optional(), - verificationCode: z.string().trim().max(20).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') @@ -95,12 +111,105 @@ function sessionMetadata(request) { } } +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' + + 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() + + // A production MailProvider will deliver the code. Local development + // intentionally exposes it so the repository remains self-contained. + return responseData( + reply, + isProduction ? { expiresAt } : { expiresAt, developmentCode: code }, + '验证码已发送' + ) + } + ) + app.post( '/api/v1/auth/register', { @@ -131,6 +240,39 @@ export async function authRoutes(app) { 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) @@ -171,6 +313,38 @@ export async function authRoutes(app) { 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) @@ -179,6 +353,9 @@ export async function authRoutes(app) { 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' diff --git a/server/routes/courses.js b/server/routes/courses.js new file mode 100644 index 0000000..0e1b522 --- /dev/null +++ b/server/routes/courses.js @@ -0,0 +1,524 @@ +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, + tp.school AS teacher_school, + tp.title AS teacher_title, + tp.rating AS teacher_rating + 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 +} + +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'") + } 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 + ${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 ( + course.status !== 'published' && + !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 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/teachers.js b/server/routes/teachers.js new file mode 100644 index 0000000..681253e --- /dev/null +++ b/server/routes/teachers.js @@ -0,0 +1,234 @@ +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(), + verified: z + .enum(['true', 'false']) + .transform((value) => value === 'true') + .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, verified } = + result.data + const conditions = ["u.role = 'teacher'", "u.status = 'active'"] + 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) + } + + if (verified !== undefined) { + conditions.push( + verified ? 'tp.verified_at IS NOT NULL' : 'tp.verified_at IS NULL' + ) + } + + 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' + 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/test/appointment-workflow.test.js b/server/test/appointment-workflow.test.js new file mode 100644 index 0000000..c5fbf8d --- /dev/null +++ b/server/test/appointment-workflow.test.js @@ -0,0 +1,433 @@ +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) + assert.equal(created.statusCode, 201) + const appointmentId = readBody(created).data.id + assert.equal(readBody(created).data.status, 'pending') + + 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, + 2 + ) + 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']) + ) +}) + +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('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 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/auth.test.js b/server/test/auth.test.js index 900b7c4..b5e455a 100644 --- a/server/test/auth.test.js +++ b/server/test/auth.test.js @@ -32,6 +32,7 @@ async function createAuthTestApp(t) { await app.register(cookie) await authPlugin(app, { + appOrigin: 'http://localhost:5173', db, secureCookies: false, sessionTtlSeconds: 3600 @@ -55,14 +56,25 @@ async function createAuthTestApp(t) { } 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: 'student@example.com', + email, password: 'Secure123!', - role: 'student', + role, displayName: '测试学生', + verificationCode, ...overrides } }) @@ -114,13 +126,26 @@ test('register rejects duplicate accounts and administrator self-registration', const first = await register(app) assert.equal(first.statusCode, 201) - const duplicate = await register(app) + 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 register(app, { - email: 'admin-signup@example.com', - role: 'administrator' + 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) }) @@ -325,3 +350,21 @@ test('logout revokes the session and bearer authentication remains API-compatibl 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/course-workflow.test.js b/server/test/course-workflow.test.js new file mode 100644 index 0000000..ffa7134 --- /dev/null +++ b/server/test/course-workflow.test.js @@ -0,0 +1,324 @@ +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) + } + } + + await app.ready() + t.after(async () => { + await app.close() + db.close() + }) + + return { app, db, accounts } +} + +test('course workflow enforces ownership and closes the reject-revise-approve loop', async (t) => { + const { app, db, accounts } = 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) + + 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) + + 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') + + 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 index d91d27e..a58bde5 100644 --- a/server/test/database.test.js +++ b/server/test/database.test.js @@ -14,6 +14,7 @@ import { const requiredTables = [ 'users', + 'teacher_profiles', 'sessions', 'verification_codes', 'courses', @@ -25,7 +26,10 @@ const requiredTables = [ 'submissions', 'chat_messages', 'notifications', - 'audit_logs' + 'audit_logs', + 'files', + 'dialogue_sessions', + 'dialogue_turns' ] function createTemporaryDatabase(t, options = {}) { From 96cacc55503bc03951ddd2dbf563bd05ded1cdb3 Mon Sep 17 00:00:00 2001 From: Henrythefoodie <13022037121@163.com> Date: Sun, 19 Jul 2026 22:46:56 +0800 Subject: [PATCH 04/20] feat(homework): add submission and grading workflow --- server/app.js | 2 + server/routes/assignments.js | 1278 +++++++++++++++++++++++ server/test/assignment-workflow.test.js | 385 +++++++ 3 files changed, 1665 insertions(+) create mode 100644 server/routes/assignments.js create mode 100644 server/test/assignment-workflow.test.js diff --git a/server/app.js b/server/app.js index ea1dd38..e4637a9 100644 --- a/server/app.js +++ b/server/app.js @@ -13,6 +13,7 @@ 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' @@ -98,6 +99,7 @@ export async function buildApp({ await app.register(authRoutes) await app.register(profileRoutes) await app.register(courseRoutes) + await app.register(assignmentRoutes) await app.register(adminRoutes) await app.register(teacherRoutes) await app.register(appointmentRoutes) diff --git a/server/routes/assignments.js b/server/routes/assignments.js new file mode 100644 index 0000000..b83bcb0 --- /dev/null +++ b/server/routes/assignments.js @@ -0,0 +1,1278 @@ +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 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 } + } + + 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 === '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.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 ( + assignment.status !== 'published' || + assignment.course_status !== 'published' + ) { + 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 !== 'published' || + assignment.course_status !== 'published' + ) { + return { error: 'NOT_AVAILABLE', status: assignment.status } + } + + 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 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 } + } + + 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 + }) + } + 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/test/assignment-workflow.test.js b/server/test/assignment-workflow.test.js new file mode 100644 index 0000000..dfd69cb --- /dev/null +++ b/server/test/assignment-workflow.test.js @@ -0,0 +1,385 @@ +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) +}) From 6c498379bd8b850be9324ee52b2594d5a3fef8e8 Mon Sep 17 00:00:00 2001 From: Henrythefoodie <13022037121@163.com> Date: Sun, 19 Jul 2026 23:16:31 +0800 Subject: [PATCH 05/20] feat(platform): add notifications files and dialogue services --- server/app.js | 10 + server/routes/dialogues.js | 313 ++++++++++++++++++++++++++ server/routes/files.js | 322 +++++++++++++++++++++++++++ server/routes/notifications.js | 155 +++++++++++++ server/services/dialogue-provider.js | 146 ++++++++++++ server/test/dialogues.test.js | 152 +++++++++++++ server/test/files.test.js | 190 ++++++++++++++++ server/test/notifications.test.js | 154 +++++++++++++ 8 files changed, 1442 insertions(+) create mode 100644 server/routes/dialogues.js create mode 100644 server/routes/files.js create mode 100644 server/routes/notifications.js create mode 100644 server/services/dialogue-provider.js create mode 100644 server/test/dialogues.test.js create mode 100644 server/test/files.test.js create mode 100644 server/test/notifications.test.js diff --git a/server/app.js b/server/app.js index e4637a9..a090d58 100644 --- a/server/app.js +++ b/server/app.js @@ -4,6 +4,7 @@ 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' @@ -17,6 +18,9 @@ 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 teacherRoutes from './routes/teachers.js' @@ -87,6 +91,9 @@ export async function buildApp({ timeWindow: config.rateLimitWindow }) await app.register(websocket) + await app.register(multipart, { + limits: { files: 1, fileSize: 50 * 1024 * 1024, fields: 10 } + }) if (database) { await authPlugin(app, { @@ -100,6 +107,9 @@ export async function buildApp({ 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(adminRoutes) await app.register(teacherRoutes) await app.register(appointmentRoutes) diff --git a/server/routes/dialogues.js b/server/routes/dialogues.js new file mode 100644 index 0000000..b0bc343 --- /dev/null +++ b/server/routes/dialogues.js @@ -0,0 +1,313 @@ +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') }, + 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) + const generated = await provider.reply({ + keywords: parseKeywords(session.keywords_json), + history: turns.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..0eec91a --- /dev/null +++ b/server/routes/files.js @@ -0,0 +1,322 @@ +import { createHash, randomUUID } from 'node:crypto' +import { basename, extname, join, relative, resolve } from 'node:path' +import { createReadStream } from 'node:fs' +import { mkdir, stat, unlink, writeFile } 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 + }, + course_cover: { + maxBytes: 8 * 1024 * 1024, + mimeTypes: new Set(['image/jpeg', 'image/png', 'image/webp']), + public: true + }, + course_video: { + maxBytes: 50 * 1024 * 1024, + mimeTypes: new Set(['video/mp4', 'video/webm']), + public: false + }, + course_material: { + maxBytes: 20 * 1024 * 1024, + mimeTypes: new Set(['application/pdf']), + public: false + } +}) + +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])) return 'image/jpeg' + if (startsWith(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + return 'image/png' + } + if ( + startsWith(buffer, [0x52, 0x49, 0x46, 0x46]) && + startsWith(buffer, [0x57, 0x45, 0x42, 0x50], 8) + ) { + return 'image/webp' + } + if (startsWith(buffer, [0x25, 0x50, 0x44, 0x46, 0x2d])) { + return 'application/pdf' + } + if (startsWith(buffer, [0x66, 0x74, 0x79, 0x70], 4)) return 'video/mp4' + if (startsWith(buffer, [0x1a, 0x45, 0xdf, 0xa3])) return 'video/webm' + return null +} + +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') + + app.post( + '/api/v1/files', + { preHandler: app.authenticate }, + 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] + let part + try { + part = await request.file({ + limits: { files: 1, fileSize: policy.maxBytes } + }) + } catch (error) { + if (error?.code === 'FST_REQ_FILE_TOO_LARGE') { + return responseError(reply, 413, '文件超过该类型允许的大小') + } + throw error + } + + if (!part) return responseError(reply, 400, '请选择需要上传的文件') + + let buffer + try { + buffer = await part.toBuffer() + } catch (error) { + if (error?.code === 'FST_REQ_FILE_TOO_LARGE') { + return responseError(reply, 413, '文件超过该类型允许的大小') + } + throw error + } + + if (part.file.truncated || buffer.length > policy.maxBytes) { + return responseError(reply, 413, '文件超过该类型允许的大小') + } + if (buffer.length === 0) return responseError(reply, 400, '文件不能为空') + + const detectedMimeType = detectMimeType(buffer) + if (!detectedMimeType || !policy.mimeTypes.has(detectedMimeType)) { + return responseError(reply, 415, '文件内容与所选类型不匹配') + } + + 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 sha256 = createHash('sha256').update(buffer).digest('hex') + const createdAt = new Date().toISOString() + + await mkdir(targetDir, { recursive: true }) + await writeFile(targetFile, buffer, { flag: 'wx' }) + + try { + 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, + buffer.length, + sha256, + category, + createdAt + ) + } catch (error) { + await unlink(targetFile).catch(() => {}) + throw error + } + + const row = db.prepare('SELECT * FROM files WHERE id = ?').get(id) + return responseData(reply, fileData(row), '文件已上传', 201) + } + ) + + 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 + } + + 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, '没有权限删除文件') + } + + db.prepare('DELETE FROM files WHERE id = ?').run(row.id) + const targetFile = resolveStoredFile(uploadDir, row.storage_key) + if (targetFile) await unlink(targetFile).catch(() => {}) + 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/services/dialogue-provider.js b/server/services/dialogue-provider.js new file mode 100644 index 0000000..aa1ab91 --- /dev/null +++ b/server/services/dialogue-provider.js @@ -0,0 +1,146 @@ +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() + +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), + signal: AbortSignal.timeout(10_000) + }) + + if (!response.ok) { + throw new Error(`AI provider returned HTTP ${response.status}`) + } + + return schema.parse(await response.json()) +} + +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/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..9f29c7b --- /dev/null +++ b/server/test/files.test.js @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict' +import { Buffer } from 'node:buffer' +import { randomUUID } from 'node:crypto' +import { mkdtemp, 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' + +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}` + } +} + +async function createTestApp(t) { + 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 }) + + 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 of ['owner', 'other']) { + const id = randomUUID() + db.prepare( + `INSERT INTO users ( + id, email, password_hash, role, display_name, status, created_at, updated_at + ) VALUES (?, ?, ?, 'teacher', ?, 'active', ?, ?)` + ).run(id, `${name}@file.test`, passwordHash, 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, users } +} + +test('validated image upload is public while metadata and deletion remain owned', async (t) => { + const { app, db, users } = await createTestApp(t) + const png = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.from('portfolio-image') + ]) + 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, 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) + + const pdf = Buffer.from('%PDF-1.7 portfolio material') + const material = multipartBody({ + content: pdf, + filename: 'lesson.pdf', + mimeType: 'application/pdf' + }) + 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, 200) + assert.deepEqual(authenticatedContent.rawPayload, pdf) +}) 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 + ) +}) From 73cc8e244900b45baab73bb4007544c690d5a913 Mon Sep 17 00:00:00 2001 From: Henrythefoodie <13022037121@163.com> Date: Sun, 19 Jul 2026 23:20:16 +0800 Subject: [PATCH 06/20] feat(profile): add teacher professional settings --- server/routes/profile.js | 124 ++++++++++++++++++++++++++++++++-- server/test/profile.test.js | 129 ++++++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+), 7 deletions(-) create mode 100644 server/test/profile.test.js diff --git a/server/routes/profile.js b/server/routes/profile.js index 5253309..7bc183b 100644 --- a/server/routes/profile.js +++ b/server/routes/profile.js @@ -19,6 +19,22 @@ const avatarUrlSchema = z '头像地址格式不正确' ) +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(), @@ -30,7 +46,8 @@ const profileSchema = z .enum(['beginner', 'HSK1', 'HSK2', 'HSK3', 'HSK4', 'HSK5', 'HSK6']) .nullable() .optional(), - bio: z.string().trim().max(500).optional() + bio: z.string().trim().max(500).optional(), + teacherProfile: teacherProfileSchema.optional() }) .refine((value) => Object.values(value).some((item) => item !== undefined), { message: '至少需要提供一个可修改字段' @@ -66,6 +83,58 @@ const profileColumnMap = { 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, @@ -115,7 +184,12 @@ export async function profileRoutes(app) { app.get( '/api/v1/me', { preHandler: app.authenticate }, - async (request, reply) => responseData(reply, request.auth.user) + 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( @@ -134,6 +208,10 @@ export async function profileRoutes(app) { return validationError(reply, result) } + if (result.data.teacherProfile && request.auth.user.role !== 'teacher') { + return responseError(reply, 403, '只有教师账号可以修改教师资料') + } + const assignments = [] const values = [] @@ -148,15 +226,47 @@ export async function profileRoutes(app) { assignments.push('updated_at = ?') values.push(updatedAt, request.auth.user.id) - db.prepare(`UPDATE users SET ${assignments.join(', ')} WHERE id = ?`).run( - ...values - ) + const updateProfile = db.transaction(() => { + 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) + } + }) + updateProfile() const user = db .prepare('SELECT * FROM users WHERE id = ? LIMIT 1') .get(request.auth.user.id) - return responseData(reply, publicUser(user), '个人资料已更新') + return responseData(reply, profileData(db, user), '个人资料已更新') } ) @@ -241,7 +351,7 @@ export async function profileRoutes(app) { .prepare('SELECT * FROM users WHERE id = ? LIMIT 1') .get(user.id) - return responseData(reply, publicUser(updatedUser), '密码已更新') + return responseData(reply, profileData(db, updatedUser), '密码已更新') } ) } diff --git a/server/test/profile.test.js b/server/test/profile.test.js new file mode 100644 index 0000000..a16323f --- /dev/null +++ b/server/test/profile.test.js @@ -0,0 +1,129 @@ +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, + created_at, updated_at + ) VALUES (?, '初始学校', '讲师', 2, '["口语"]', '[]', '[]', '["中文"]', ?, ?)` + ).run(users.teacher.id, 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, ['口语']) + + 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( + db + .prepare( + 'SELECT hourly_rate_cents FROM teacher_profiles WHERE user_id = ?' + ) + .get(users.teacher.id).hourly_rate_cents, + 18800 + ) +}) + +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 + ) +}) From ce5720c14b071e805b9515c6bfc48c08cce6be72 Mon Sep 17 00:00:00 2001 From: Henrythefoodie <13022037121@163.com> Date: Sun, 19 Jul 2026 23:39:03 +0800 Subject: [PATCH 07/20] feat(auth): deliver verification codes through SMTP --- .env.example | 1 + package.json | 1 + pnpm-lock.yaml | 9 ++++++ server/config.js | 2 ++ server/routes/auth.js | 26 +++++++++++++++-- server/services/mail-provider.js | 46 +++++++++++++++++++++++++++++++ server/test/mail-provider.test.js | 40 +++++++++++++++++++++++++++ 7 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 server/services/mail-provider.js create mode 100644 server/test/mail-provider.test.js diff --git a/.env.example b/.env.example index e0ab231..723e716 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ VERIFICATION_CODE_SECRET= # 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= diff --git a/package.json b/package.json index feda02e..cbf1e7a 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "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", "vue": "^3.5.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65f2c9b..77d78e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: 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) @@ -1972,6 +1975,10 @@ packages: resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} engines: {node: '>=18'} + nodemailer@9.0.3: + resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} + engines: {node: '>=6.0.0'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -4508,6 +4515,8 @@ snapshots: node-releases@2.0.51: {} + nodemailer@9.0.3: {} + normalize-path@3.0.0: {} normalize-wheel-es@1.2.0: {} diff --git a/server/config.js b/server/config.js index a1e3c75..dce39d1 100644 --- a/server/config.js +++ b/server/config.js @@ -64,6 +64,8 @@ export function loadConfig(env = process.env) { logger: boolean(env.LOGGER, nodeEnv !== 'test'), seedOnStart: boolean(env.SEED_ON_START, nodeEnv !== 'production'), smtpUrl: env.SMTP_URL || '', + mailFrom: + env.MAIL_FROM || 'International Chinese Platform ', aiApiUrl: env.AI_API_URL || '', aiApiKey: env.AI_API_KEY || '', turnUrl: env.TURN_URL || '', diff --git a/server/routes/auth.js b/server/routes/auth.js index 8b6c494..fc46f56 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -3,6 +3,8 @@ import { Buffer } from 'node:buffer' import { z } from 'zod' +import { createMailProvider } from '../services/mail-provider.js' + import { hashPassword, needsPasswordRehash, @@ -139,6 +141,10 @@ export async function authRoutes(app) { 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', @@ -200,8 +206,24 @@ export async function authRoutes(app) { }) replaceCode() - // A production MailProvider will deliver the code. Local development - // intentionally exposes it so the repository remains self-contained. + 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 }, diff --git a/server/services/mail-provider.js b/server/services/mail-provider.js new file mode 100644 index 0000000..067bd16 --- /dev/null +++ b/server/services/mail-provider.js @@ -0,0 +1,46 @@ +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, + 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/mail-provider.test.js b/server/test/mail-provider.test.js new file mode 100644 index 0000000..e6aa092 --- /dev/null +++ b/server/test/mail-provider.test.js @@ -0,0 +1,40 @@ +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(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) +}) From d40f3789b5cad6be72f5b2bd3fe47110e6703edb Mon Sep 17 00:00:00 2001 From: Henrythefoodie <13022037121@163.com> Date: Sun, 19 Jul 2026 23:43:30 +0800 Subject: [PATCH 08/20] feat(classroom): add ticketed realtime collaboration --- server/app.js | 2 + server/routes/realtime.js | 745 +++++++++++++++++++++++++ server/test/realtime-classroom.test.js | 384 +++++++++++++ 3 files changed, 1131 insertions(+) create mode 100644 server/routes/realtime.js create mode 100644 server/test/realtime-classroom.test.js diff --git a/server/app.js b/server/app.js index a090d58..6d55086 100644 --- a/server/app.js +++ b/server/app.js @@ -22,6 +22,7 @@ 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 = {}) { @@ -110,6 +111,7 @@ export async function buildApp({ 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) diff --git a/server/routes/realtime.js b/server/routes/realtime.js new file mode 100644 index 0000000..927557a --- /dev/null +++ b/server/routes/realtime.js @@ -0,0 +1,745 @@ +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 MAX_EVENT_BYTES = 64 * 1024 +const MAX_CHAT_LENGTH = 4000 + +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.status, + appointment.status AS appointment_status, + 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 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 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) + 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) + 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 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 sweepTimer = setInterval(removeExpiredTickets, TICKET_SWEEP_INTERVAL_MS) + sweepTimer.unref() + + app.addHook('onClose', async () => { + clearInterval(sweepTimer) + 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, '课堂当前不可加入') + } + + removeExpiredTickets() + const { ticket, hash } = uniqueTicket(tickets) + const expiresAt = Date.now() + TICKET_TTL_MS + tickets.set(hash, { + classroomId: classroom.id, + user: participant, + participantIds: [classroom.student_id, classroom.teacher_id], + expiresAt + }) + + return responseData( + reply, + { + classroomId: classroom.id, + ticket, + expiresAt: new Date(expiresAt).toISOString(), + 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) + if ( + !classroom || + classroom.appointment_status !== 'accepted' || + classroom.status === 'closed' || + !isActiveParticipant(classroom, claim.user.id) + ) { + 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 + + socket.on('message', (data, isBinary) => { + 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/test/realtime-classroom.test.js b/server/test/realtime-classroom.test.js new file mode 100644 index 0000000..a5ce31c --- /dev/null +++ b/server/test/realtime-classroom.test.js @@ -0,0 +1,384 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +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, token: session.token } +} + +function addClassroom(database, studentId, teacherId) { + const appointmentId = randomUUID() + const classroomId = randomUUID() + const now = Date.now() + + database + .prepare( + `INSERT INTO appointments ( + id, student_id, teacher_id, scheduled_start, scheduled_end, + topic, status + ) VALUES (?, ?, ?, ?, ?, ?, 'accepted')` + ) + .run( + appointmentId, + studentId, + teacherId, + new Date(now + 60_000).toISOString(), + new Date(now + 3_660_000).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('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/) +}) + +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 + ) +}) From 0d6c2219eddfd55775c4e990854ba7489486af52 Mon Sep 17 00:00:00 2001 From: Henrythefoodie <13022037121@163.com> Date: Sun, 19 Jul 2026 23:47:36 +0800 Subject: [PATCH 09/20] fix(appointments): complete notification and classroom links --- server/routes/appointments.js | 52 ++++++++++++++++++++---- server/test/appointment-workflow.test.js | 21 +++++++++- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/server/routes/appointments.js b/server/routes/appointments.js index fb10a7b..8c76355 100644 --- a/server/routes/appointments.js +++ b/server/routes/appointments.js @@ -257,7 +257,7 @@ export async function appointmentRoutes(app) { ).toISOString() const timestamp = new Date().toISOString() - try { + const createAppointment = db.transaction(() => { db.prepare( `INSERT INTO appointments ( id, student_id, teacher_id, course_id, scheduled_start, @@ -276,6 +276,30 @@ export async function appointmentRoutes(app) { 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, '相同时间的预约已经存在') @@ -454,26 +478,26 @@ export async function appointmentRoutes(app) { insertNotification(db, { userId: appointment.student_id, - type: 'appointment_accepted', + type: 'appointment.accepted', title: '预约已被教师接受', body: result.data.note || '教师已接受你的课堂预约。', appointmentId: appointment.id, - link: `/classrooms/${classroomId}`, + link: `/student/liveClass?classroomId=${classroomId}`, dedupeKey: `appointment:${appointment.id}:accepted:${appointment.student_id}` }) insertNotification(db, { userId: appointment.teacher_id, - type: 'appointment_accepted', + type: 'appointment.accepted', title: '课堂预约已确认', body: '课堂已进入排期,并已创建专属教室。', appointmentId: appointment.id, - link: `/classrooms/${classroomId}`, + link: `/teacher/liveClass?classroomId=${classroomId}`, dedupeKey: `appointment:${appointment.id}:accepted:${appointment.teacher_id}` }) } else { insertNotification(db, { userId: appointment.student_id, - type: 'appointment_rejected', + type: 'appointment.rejected', title: '预约未被接受', body: result.data.note || '教师当前无法接受该预约。', appointmentId: appointment.id, @@ -577,7 +601,7 @@ export async function appointmentRoutes(app) { role === 'student' ? appointment.teacher_id : appointment.student_id insertNotification(db, { userId: otherParticipantId, - type: 'appointment_cancelled', + type: 'appointment.cancelled', title: '课堂预约已取消', body: result.data.reason || '预约已由另一位参与者取消。', appointmentId: appointment.id, @@ -659,9 +683,23 @@ export async function appointmentRoutes(app) { return responseError(reply, 409, '课堂当前不可加入') } + 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, + iceServers, participants: [ { id: classroom.student_id, diff --git a/server/test/appointment-workflow.test.js b/server/test/appointment-workflow.test.js index c5fbf8d..3c232b7 100644 --- a/server/test/appointment-workflow.test.js +++ b/server/test/appointment-workflow.test.js @@ -179,6 +179,24 @@ test('a student request can be accepted atomically with a classroom, notificatio 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 + ) const accepted = await app.inject({ method: 'PATCH', @@ -199,7 +217,7 @@ test('a student request can be accepted atomically with a classroom, notificatio WHERE resource_type = 'appointment' AND resource_id = ?` ) .get(appointmentId).count, - 2 + 3 ) assert.equal( database @@ -225,6 +243,7 @@ test('a student request can be accepted atomically with a classroom, notificatio 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) => { From 6ed97177c8ed0aaf7660b5133e29afd2f14a181c Mon Sep 17 00:00:00 2001 From: Henrythefoodie <13022037121@163.com> Date: Sun, 19 Jul 2026 23:57:20 +0800 Subject: [PATCH 10/20] fix(database): add ordered legacy-safe migrations --- server/db/database.js | 82 ++++++++++++++-- server/test/database.test.js | 177 +++++++++++++++++++++++++++++++++-- 2 files changed, 241 insertions(+), 18 deletions(-) diff --git a/server/db/database.js b/server/db/database.js index 2e97c48..3fbe914 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -8,7 +8,61 @@ 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') -const initialMigration = '001_initial_schema' + +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) @@ -35,17 +89,25 @@ export function migrateDatabase(database) { ) `) - const migrate = database.transaction(() => { - // The schema is intentionally idempotent. Executing it on every startup makes - // newly added indexes available to existing development databases, while the - // migration ledger still records which baseline was installed. - database.exec(schemaSql) + const applied = new Set( database - .prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)') - .run(initialMigration) - }) + .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) + })() + } - migrate() return database } diff --git a/server/test/database.test.js b/server/test/database.test.js index a58bde5..5231b8c 100644 --- a/server/test/database.test.js +++ b/server/test/database.test.js @@ -4,7 +4,13 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import test from 'node:test' -import { createDatabase, migrateDatabase } from '../db/database.js' +import Database from 'better-sqlite3' + +import { + createDatabase, + MIGRATION_IDS, + migrateDatabase +} from '../db/database.js' import { DEMO_ACCOUNTS, DEMO_PASSWORD, @@ -66,24 +72,179 @@ test('database migration creates the complete schema with safe pragmas', (t) => assert.ok(tables.has(table), `expected migration to create ${table}`) } - assert.equal( + assert.deepEqual( database - .prepare( - "SELECT COUNT(*) AS count FROM schema_migrations WHERE id = '001_initial_schema'" - ) - .get().count, - 1 + .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, - 1, + 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) From 3ddc65796732ae1a19187664b1c31ff665dc6d8a Mon Sep 17 00:00:00 2001 From: Henrythefoodie <13022037121@163.com> Date: Mon, 20 Jul 2026 00:00:30 +0800 Subject: [PATCH 11/20] feat(operations): add secure administrator bootstrap --- .env.example | 5 + package.json | 11 ++- server/db/bootstrap-admin.js | 147 ++++++++++++++++++++++++++++ server/test/bootstrap-admin.test.js | 83 ++++++++++++++++ 4 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 server/db/bootstrap-admin.js create mode 100644 server/test/bootstrap-admin.test.js diff --git a/.env.example b/.env.example index 723e716..30576d9 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,11 @@ APP_ORIGIN=http://localhost:5173 SESSION_TTL_HOURS=12 VERIFICATION_CODE_SECRET= +# 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 diff --git a/package.json b/package.json index cbf1e7a..4d57350 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,14 @@ "scripts": { "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 --watch server/index.js", - "start": "node server/index.js", + "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 server/db/migrate.js", - "db:seed": "node server/db/seed.js", - "db:reset": "node server/db/reset.js", + "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", 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/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 + ) +}) From 88825c92474b2203535c16759cedfc388754a799 Mon Sep 17 00:00:00 2001 From: Henrythefoodie <13022037121@163.com> Date: Mon, 20 Jul 2026 00:10:32 +0800 Subject: [PATCH 12/20] feat(workflows): close classroom and assignment lifecycles --- server/routes/appointments.js | 192 ++++++++++++++- server/routes/assignments.js | 146 +++++++++-- server/routes/realtime.js | 55 +++++ server/test/appointment-workflow.test.js | 186 +++++++++++++- server/test/assignment-workflow.test.js | 301 +++++++++++++++++++++++ server/test/realtime-classroom.test.js | 35 ++- 6 files changed, 892 insertions(+), 23 deletions(-) diff --git a/server/routes/appointments.js b/server/routes/appointments.js index 8c76355..6e71a8c 100644 --- a/server/routes/appointments.js +++ b/server/routes/appointments.js @@ -3,6 +3,8 @@ 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() @@ -201,6 +203,38 @@ function conflictError(error) { ) } +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) { @@ -431,8 +465,8 @@ export async function appointmentRoutes(app) { if (result.data.action === 'accept') { const conflict = db .prepare( - `SELECT id FROM appointments - WHERE teacher_id = ? + `SELECT id, teacher_id, student_id FROM appointments + WHERE (teacher_id = ? OR student_id = ?) AND status = 'accepted' AND id <> ? AND scheduled_start < ? @@ -442,6 +476,7 @@ export async function appointmentRoutes(app) { ) .get( appointment.teacher_id, + appointment.student_id, appointment.id, appointment.scheduled_end, appointment.scheduled_start @@ -449,7 +484,11 @@ export async function appointmentRoutes(app) { if (conflict) { return { error: 'SCHEDULE_CONFLICT', - conflictingAppointmentId: conflict.id + conflictingAppointmentId: conflict.id, + conflictingParticipant: + conflict.teacher_id === appointment.teacher_id + ? 'teacher' + : 'student' } } } @@ -533,7 +572,8 @@ export async function appointmentRoutes(app) { } if (outcome.error === 'SCHEDULE_CONFLICT') { return responseError(reply, 409, '该时间段与已接受的课堂冲突', { - conflictingAppointmentId: outcome.conflictingAppointmentId + conflictingAppointmentId: outcome.conflictingAppointmentId, + conflictingParticipant: outcome.conflictingParticipant }) } @@ -641,6 +681,130 @@ export async function appointmentRoutes(app) { } ) + 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 === 'closed' + ) { + 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', + opened_at = COALESCE(opened_at, ?), + closed_at = ?, + updated_at = ? + WHERE id = ? AND status IN ('scheduled', 'open')` + ) + .run(timestamp, 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 }, @@ -652,6 +816,8 @@ export async function appointmentRoutes(app) { 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, @@ -683,6 +849,18 @@ export async function appointmentRoutes(app) { 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({ @@ -699,6 +877,12 @@ export async function appointmentRoutes(app) { 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: [ { diff --git a/server/routes/assignments.js b/server/routes/assignments.js index b83bcb0..702ef03 100644 --- a/server/routes/assignments.js +++ b/server/routes/assignments.js @@ -193,6 +193,10 @@ 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 }) } @@ -788,6 +792,9 @@ export async function assignmentRoutes(app) { 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( @@ -839,6 +846,11 @@ export async function assignmentRoutes(app) { 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, @@ -860,6 +872,69 @@ export async function assignmentRoutes(app) { } ) + 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 }, @@ -873,8 +948,8 @@ export async function assignmentRoutes(app) { const { role, id: userId } = request.auth.user if (role === 'student') { if ( - assignment.status !== 'published' || - assignment.course_status !== 'published' + !['published', 'closed'].includes(assignment.status) || + !['published', 'archived'].includes(assignment.course_status) ) { return responseError(reply, 404, '作业不存在') } @@ -917,20 +992,22 @@ export async function assignmentRoutes(app) { const save = db.transaction(() => { const assignment = findAssignment(db, paramsResult.data.id) if (!assignment) return { error: 'NOT_FOUND' } - if ( - assignment.status !== 'published' || - assignment.course_status !== 'published' - ) { + if (assignment.status === 'closed') { + return { error: 'ASSIGNMENT_CLOSED' } + } + if (assignment.status !== 'published') { return { error: 'NOT_AVAILABLE', status: assignment.status } } - - const questions = findQuestions(db, assignment.id) - const issues = answerErrors( - questions, - input.answers, - input.action === 'submit' - ) - if (issues.length > 0) return { error: 'INVALID_ANSWERS', issues } + 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( @@ -942,6 +1019,26 @@ export async function assignmentRoutes(app) { 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' @@ -1014,7 +1111,26 @@ export async function assignmentRoutes(app) { } if (outcome.error === 'NOT_AVAILABLE') { return responseError(reply, 409, '作业当前不可提交', { - assignmentStatus: outcome.status + 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') { diff --git a/server/routes/realtime.js b/server/routes/realtime.js index 927557a..9438356 100644 --- a/server/routes/realtime.js +++ b/server/routes/realtime.js @@ -5,6 +5,8 @@ import { z } from 'zod' const TICKET_TTL_MS = 60_000 const TICKET_SWEEP_INTERVAL_MS = 30_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 @@ -174,8 +176,11 @@ function findClassroom(db, classroomId) { .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, @@ -217,6 +222,32 @@ function isActiveParticipant(classroom, userId) { 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 @@ -584,6 +615,18 @@ export async function realtimeRoutes(app) { 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 @@ -600,6 +643,13 @@ export async function realtimeRoutes(app) { 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)}` }, '连接票据已创建', @@ -668,10 +718,15 @@ export async function realtimeRoutes(app) { 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) ) { return responseError(reply, 403, '当前无法加入课堂') diff --git a/server/test/appointment-workflow.test.js b/server/test/appointment-workflow.test.js index 3c232b7..6ff9e08 100644 --- a/server/test/appointment-workflow.test.js +++ b/server/test/appointment-workflow.test.js @@ -175,7 +175,9 @@ test('a student request can be accepted atomically with a classroom, notificatio addTeacherProfile(database, teacher.id) const courseId = addCourse(database, teacher.id) - const created = await requestAppointment(app, student, teacher, courseId) + 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') @@ -387,6 +389,180 @@ test('accepting an overlapping lesson is rejected without partial side effects', ) }) +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 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, { @@ -441,6 +617,14 @@ test('appointment ownership prevents response, listing and classroom access leak }) 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, diff --git a/server/test/assignment-workflow.test.js b/server/test/assignment-workflow.test.js index dfd69cb..74a97bf 100644 --- a/server/test/assignment-workflow.test.js +++ b/server/test/assignment-workflow.test.js @@ -383,3 +383,304 @@ test('assignment visibility, ownership and state transitions stay explicit', asy }) 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/realtime-classroom.test.js b/server/test/realtime-classroom.test.js index a5ce31c..bad1ee8 100644 --- a/server/test/realtime-classroom.test.js +++ b/server/test/realtime-classroom.test.js @@ -32,10 +32,16 @@ function addUser(database, { id = randomUUID(), role, displayName }) { return { id, role, displayName, token: session.token } } -function addClassroom(database, studentId, teacherId) { +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( @@ -48,8 +54,8 @@ function addClassroom(database, studentId, teacherId) { appointmentId, studentId, teacherId, - new Date(now + 60_000).toISOString(), - new Date(now + 3_660_000).toISOString(), + scheduledStart.toISOString(), + scheduledEnd.toISOString(), '实时课堂测试' ) @@ -190,6 +196,29 @@ test('only classroom participants can issue tickets or read history', async (t) 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, { From 78ed15d5032362733da84b40a7c8a6ac661ed851 Mon Sep 17 00:00:00 2001 From: Henrythefoodie <13022037121@163.com> Date: Mon, 20 Jul 2026 00:25:15 +0800 Subject: [PATCH 13/20] feat(frontend): connect secure role workspaces --- .env.example | 3 +- src/api/common.js | 16 - src/api/platform.js | 193 +++++++ src/api/student.js | 163 ------ src/api/universal.js | 12 +- src/api/user.js | 42 +- src/components/account/NotificationCenter.vue | 347 ++++++++++++ src/components/account/PasswordForm.vue | 179 ++++++ src/components/account/ProfileForm.vue | 533 ++++++++++++++++++ src/components/basic/tabSwitch.vue | 15 +- src/config/runtime.js | 63 +-- src/router/index.js | 78 +-- src/stores/modules/user.js | 137 ++++- src/utils/request.js | 43 +- .../administratorLayout.vue | 311 +++++----- .../personalCenter/changePassword.vue | 58 +- .../personalCenter/messageNotice.vue | 175 +----- .../personalCenter/personalCenter.vue | 4 +- .../login/components/registerComponent.vue | 301 +++++----- src/views/login/loginPage.vue | 40 +- .../student/orderTeacher/createNewChat.vue | 81 --- .../student/personalCenter/changePassword.vue | 58 +- .../student/personalCenter/messageNotice.vue | 175 +----- .../student/personalCenter/personalCenter.vue | 9 +- .../student/personalCenter/personalInfo.vue | 117 +--- src/views/student/personalCenter/vipInfo.vue | 9 - .../student/publishOrder/CreateNewPublish.vue | 69 --- .../student/publishOrder/publishOrder.vue | 336 ----------- .../student/studentLayout/studentLayout.vue | 429 +++++++------- src/views/teacher/UserPage/UserPage.vue | 67 ++- src/views/teacher/components/HeaderNav.vue | 255 ++++----- 31 files changed, 2229 insertions(+), 2089 deletions(-) delete mode 100644 src/api/common.js create mode 100644 src/api/platform.js delete mode 100644 src/api/student.js create mode 100644 src/components/account/NotificationCenter.vue create mode 100644 src/components/account/PasswordForm.vue create mode 100644 src/components/account/ProfileForm.vue delete mode 100644 src/views/student/orderTeacher/createNewChat.vue delete mode 100644 src/views/student/personalCenter/vipInfo.vue delete mode 100644 src/views/student/publishOrder/CreateNewPublish.vue delete mode 100644 src/views/student/publishOrder/publishOrder.vue diff --git a/.env.example b/.env.example index 30576d9..1bab5b9 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ -# The browser uses Vite's same-origin proxy by default. Set only for a split deployment. +# 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 # API server settings. The database and uploads stay local unless explicitly changed. 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..df68307 --- /dev/null +++ b/src/api/platform.js @@ -0,0 +1,193 @@ +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 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..ad565d4 --- /dev/null +++ b/src/components/account/ProfileForm.vue @@ -0,0 +1,533 @@ + + + + + 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') + } }