diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3f1703f..97b8d86 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -24,4 +24,6 @@ jobs: with: context: ./backend push: true - tags: ghcr.io/${{ github.repository_owner }}/backend:latest + tags: | + ghcr.io/${{ github.repository_owner }}/backend:${{ github.sha }} + ghcr.io/${{ github.repository_owner }}/backend:latest diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 09f1f09..ae154b3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -67,7 +67,9 @@ Important paths: - `backend/alembic/versions/` contains database migrations. - `backend/tests/` contains the backend test suite. -The application exposes `/health/live` and database-backed `/health/ready`. HTTP responses include a privacy-safe request ID and baseline security headers; backend request logs record method, path, status, duration, and correlation ID without logging bearer tokens. These are foundations rather than a complete metrics, tracing, or deployment platform; see [docs/platform-maturity](docs/platform-maturity/observability-baseline.md). +The application exposes process-only `/health/live` and database-backed `/health/ready`. HTTP responses include a privacy-safe request ID and optional bounded correlation ID. Backend middleware records normalized route, method, status family, and duration through the structured/redacted logging and in-process metrics foundation. Optional Prometheus text export at `/internal/metrics` is disabled by default and token protected when enabled. These are vendor-neutral operational foundations, not distributed tracing, a durable audit ledger, or a production hosting architecture; see [the observability policy](docs/observability/logging-policy.md) and [runbook](docs/operations/observability-runbook.md). + +Backend authorization uses explicit platform/organization role allowlists plus reusable organization, section, course-content, forum-ownership, and learner-progress scope checks. Sensitive routes use a configurable process-local fixed-window limiter; it is suitable only for the checked-in single-Uvicorn-process topology and must be replaced by shared storage before horizontal scaling. The canonical policy and limitations are in [docs/security/role-authorization-policy.md](docs/security/role-authorization-policy.md) and [docs/security/rate-limiting-policy.md](docs/security/rate-limiting-policy.md). ## Existing Domain Boundaries @@ -140,3 +142,7 @@ cmd /c .\node_modules\.bin\playwright.cmd test tests/demo/student-flagship-smoke ## Contribution Guidance Changes should be small, scoped, and aligned with existing boundaries. Documentation-only changes do not need backend migrations or frontend tests, but they should still pass OpenSpec validation when tied to an OpenSpec change. + +## Operational lifecycle + +`app.operational_config` validates environment, database, secret, host/proxy, origin, release, storage, migration, and observability decisions before route/database initialization. `TrustedHostMiddleware` and peer-CIDR forwarding resolution define the application network boundary; Uvicorn implicit proxy processing is disabled. Schema migration is a one-shot pre-start operation, health separates process liveness from database readiness, and the FastAPI lifespan disposes the SQLAlchemy engine during bounded shutdown. See [deployment architecture and limits](docs/operations/deployment-runbook.md). diff --git a/README.md b/README.md index 63dbe5a..5950265 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,10 @@ For a deeper overview, see [ARCHITECTURE.md](ARCHITECTURE.md). Platform maturity evidence, including the measured frontend bundle work and the security, observability, operations, dependency, and backend-capability baselines, is indexed in [docs/platform-maturity](docs/platform-maturity/phase-7-baseline.md). These documents are readiness inputs; they do not claim that EchoEd 1.0 is production-ready. +Phase 10 adds vendor-neutral structured logging, request correlation, liveness/readiness, protected optional metrics export, safe frontend support references, and operational guidance. Configuration and endpoint policy are documented under [docs/observability](docs/observability/logging-policy.md); this is not a commercial monitoring integration or durable audit system. + +Phase 8 security-hardening evidence and operator-facing limitations are indexed in [docs/security/phase-8-security-baseline.md](docs/security/phase-8-security-baseline.md). Configure rate limits with the documented `RATE_LIMIT__LIMIT` and `RATE_LIMIT__WINDOW_SECONDS` variables; the current store is process-local and forwarded client-IP headers are intentionally ignored. + ## Local Development ### Backend @@ -151,6 +155,10 @@ cd frontend cmd /c .\node_modules\.bin\playwright.cmd test tests/demo/student-flagship-smoke.spec.ts ``` +## Operations + +Production startup is governed by the [production configuration contract](docs/operations/production-configuration.md). Migrations are an explicit release step; normal backend startup does not change schema. Start with the [deployment runbook](docs/operations/deployment-runbook.md), [backup/restore procedure](docs/operations/backup-and-restore.md), and [operational drills](docs/operations/operational-drills.md). Passing local readiness drills is not by itself a production-readiness claim. + ## Contributing EchoEd welcomes bounded, respectful contributions. Because this project is early and has no paid budget, contribution requests should be specific and transparent. diff --git a/ROADMAP.md b/ROADMAP.md index 5c7ac8a..0718359 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -63,6 +63,12 @@ These are not Phase 1 requirements: Phase 7 preserves the completed role-based experience while reducing initial frontend loading cost and establishing evidence-based security, observability, operational, dependency, and backend-capability baselines. Larger product capabilities remain independent future OpenSpec changes. See the [platform-maturity roadmap](docs/platform-maturity/future-openspec-roadmap.md) for priorities and dependencies; passing this foundation does not make EchoEd 1.0 production-ready. +Phase 8 (`harden-platform-security`) hardens the evidenced forum, privileged-user, role, rate-limit, upload, response-minimization, and object/organization authorization boundaries. Its verification evidence lives under [docs/security](docs/security/phase-8-security-verification.md). Remaining platform-maturity work continues as independent OpenSpec changes. + +Phase 10 (`establish-platform-observability`) establishes privacy-conscious structured logs, request references, operational metrics, dependency readiness, Course Studio diagnostics, and operator/incident guidance without selecting a monitoring vendor. Durable, access-controlled, tamper-resistant administrative history remains the separate `implement-platform-audit-events` change. Operational readiness is the recommended next platform phase; observability alone is not a production-readiness claim. + +Phase 11 (`establish-operational-readiness`) builds directly on Phase 10 with fail-closed production configuration, trusted host/proxy boundaries, explicit migrations, deployment/rollback gates, graceful shutdown, initial SLO/alert contracts, backup/restore tooling, storage ownership, secret rotation, and evidence-driven drills. Hosting selection, external alert delivery, distributed state/telemetry, and durable audit events remain separate work. + ## Roadmap Principles - Trust before scale. diff --git a/SECURITY.md b/SECURITY.md index 5084bca..33980c6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,9 +15,9 @@ Security reports may cover: ## Current Baseline -The focused [Phase 7 security baseline](docs/platform-maturity/security-baseline.md) records repository evidence, severity, narrow remediations, and deferred security work. Phase 7 adds privacy-safe authentication logging, authenticated diagnostic access, active organization-membership enforcement, bounded image-upload validation, request correlation, baseline response headers, and patched Angular runtime packages. It is not a penetration test. +The focused [Phase 7 security baseline](docs/platform-maturity/security-baseline.md) records the prior evidence. The [Phase 8 security baseline](docs/security/phase-8-security-baseline.md), [threat model](docs/security/phase-8-threat-model.md), and linked policies document backend-enforced forum ownership, privileged-user invariants, role allowlists, configurable rate limits, upload signature checks, minimized responses, and expanded object/organization tests. These are scoped hardening controls, not a penetration test or production-readiness certification. -The unauthenticated forum mutation boundary, administrative response minimization, rate limiting, comprehensive audit events, and production security policy remain explicit future work. Do not use the current demo with real learner or production data. +Durable audit events, distributed rate-limit storage, private/scanned asset delivery, session revocation, production proxy/host/CSP/HSTS validation, and formal privacy/retention work remain explicit future work. Do not use the current demo with real learner or production data. ## Reporting a Vulnerability @@ -61,6 +61,12 @@ This is a no-budget early project, so response time may vary. The intended respo Please do not publicly disclose a suspected vulnerability until there has been a reasonable opportunity to investigate and mitigate it. +## Diagnostic References and Sensitive Evidence + +Unexpected API failures may display a bounded request reference. It is safe to include that reference, the approximate time, the action, and a non-sensitive page name in a report. Do not provide passwords, tokens, cookies, authorization headers, invitation/reset links, uploaded files, learner records, assessment responses, or private course content. Backend operational logs and metrics are privacy-redacted diagnostics; they are not a durable or tamper-resistant audit record. See the [observability runbook](docs/operations/observability-runbook.md). + +Production configuration fails closed and never loads dotenv. Allowed hosts are enforced, and forwarded client/protocol/host metadata is ignored unless the direct peer belongs to an explicitly configured CIDR. Operators must never attach secrets, database URLs, backup contents, or raw environment dumps to issues; share only setting categories, safe request references, release identifiers, timestamps, and pass/fail results. See the [production configuration contract](docs/operations/production-configuration.md). + ## Out of Scope The following are out of scope unless they demonstrate a concrete security impact: diff --git a/backend/alembic/env.py b/backend/alembic/env.py index cdab150..e9b2734 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -8,7 +8,8 @@ from alembic import context from app.models import Base -load_dotenv() +if os.getenv("APP_ENV", "development").strip().lower() != "production": + load_dotenv() db_url = os.getenv("DATABASE_URL") diff --git a/backend/app/api/routes/activities.py b/backend/app/api/routes/activities.py index 2a2ff8b..2b8a86c 100644 --- a/backend/app/api/routes/activities.py +++ b/backend/app/api/routes/activities.py @@ -5,6 +5,7 @@ from app.database import get_db from app.deps import require_roles from app.models import Activity +from app.content_scope import course_for_activity, course_for_lesson, require_course_edit from app.schemas import ActivityResponse from pydantic import BaseModel @@ -24,8 +25,9 @@ class ActivityUpdate(ActivityCreate): def create_activity( activity: ActivityCreate, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin")), ): + require_course_edit(db, current_user, course_for_lesson(db, activity.lesson_id)) new_activity = Activity( lesson_id=activity.lesson_id, type=activity.type, @@ -41,7 +43,7 @@ def create_activity( @router.get('/activities', response_model=list[ActivityResponse]) def list_activities( db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin")), ): return db.query(Activity).all() @@ -49,7 +51,7 @@ def list_activities( def get_activity( activity_id: UUID, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher", "student")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin", "student")), ): activity = db.query(Activity).filter_by(id=activity_id).first() if not activity: @@ -61,11 +63,13 @@ def update_activity( activity_id: UUID, activity: ActivityUpdate, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin")), ): db_activity = db.query(Activity).filter_by(id=activity_id).first() if not db_activity: raise HTTPException(status_code=404, detail='Activity not found') + require_course_edit(db, current_user, course_for_activity(db, db_activity.id)) + require_course_edit(db, current_user, course_for_lesson(db, activity.lesson_id)) db_activity.lesson_id = activity.lesson_id db_activity.type = activity.type db_activity.title = activity.title @@ -79,11 +83,12 @@ def update_activity( def delete_activity( activity_id: UUID, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin")), ): db_activity = db.query(Activity).filter_by(id=activity_id).first() if not db_activity: raise HTTPException(status_code=404, detail='Activity not found') + require_course_edit(db, current_user, course_for_activity(db, db_activity.id)) db.delete(db_activity) db.commit() return {'message': 'Activity deleted'} diff --git a/backend/app/api/routes/assignments.py b/backend/app/api/routes/assignments.py index c7a034a..1938d60 100644 --- a/backend/app/api/routes/assignments.py +++ b/backend/app/api/routes/assignments.py @@ -1,12 +1,13 @@ from datetime import datetime +from uuid import UUID from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from app.database import get_db from app.deps import get_current_user, require_org_roles -from app.models import Assignment, AssignmentSubmission -from app.section_scope import require_scoped_section -from app.enum import AssignmentTargetType, AssignmentSubmissionStatus +from app.models import Assignment, AssignmentSubmission, Enrollment +from app.section_scope import require_scoped_section, require_section_lesson, require_section_unit +from app.enum import AssignmentTargetType, AssignmentSubmissionStatus, EnrollmentStatus from app.schemas import ( AssignmentCreateRequest, AssignmentResponse, @@ -26,9 +27,14 @@ def create_assignment( membership=Depends(require_org_roles("teacher", "org_admin", "instructor")), ): section = require_scoped_section(db, membership, section_id) + target_type = AssignmentTargetType(payload.target_type) + if target_type == AssignmentTargetType.UNIT: + require_section_unit(db, section, payload.target_id) + else: + require_section_lesson(db, section, payload.target_id) assignment = Assignment( section_id=section.id, - target_type=AssignmentTargetType(payload.target_type), + target_type=target_type, target_id=payload.target_id, due_at=payload.due_at, instructions=payload.instructions, @@ -52,11 +58,25 @@ def list_assignments( @router.post("/assignments/{assignment_id}/submit", response_model=AssignmentSubmissionResponse) def submit_assignment( - assignment_id: str, + assignment_id: UUID, payload: AssignmentSubmissionRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user), ): + assignment = db.query(Assignment).filter(Assignment.id == assignment_id).first() + if assignment is None: + raise HTTPException(status_code=404, detail="Assignment not found") + enrollment = ( + db.query(Enrollment) + .filter( + Enrollment.section_id == assignment.section_id, + Enrollment.user_id == current_user.id, + Enrollment.status == EnrollmentStatus.ACTIVE, + ) + .first() + ) + if enrollment is None: + raise HTTPException(status_code=404, detail="Assignment not found") submission = ( db.query(AssignmentSubmission) .filter( diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 0f97f90..bf7734e 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -1,6 +1,6 @@ from datetime import timedelta -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session @@ -20,12 +20,17 @@ ) from app.schemas import AuthTokenResponse, UserDto from app.enum import OrganizationType, OrganizationRole +from app.rate_limit import enforce_rate_limit +from app.security import PUBLIC_REGISTRATION_ROLES, security_event +from app.observability import emit_event, metrics router = APIRouter() @router.post("/auth/register") -def register_user(user: UserDto, db: Session = Depends(get_db)): +def register_user(user: UserDto, request: Request, db: Session = Depends(get_db)): + metrics.increment("echoed_authentication_total", operation="registration", result="attempt") + enforce_rate_limit(request, "auth_register", account_identifier=user.username) existing_user = db.query(User).filter(User.username == user.username).first() if existing_user: raise HTTPException(status_code=400, detail="Username already registered") @@ -35,7 +40,9 @@ def register_user(user: UserDto, db: Session = Depends(get_db)): firstname=user.firstname, lastname=user.lastname, email=user.email, - role=user.role.lower(), + role=(user.role or "student").lower() + if (user.role or "student").lower() in PUBLIC_REGISTRATION_ROLES + else "student", hashed_password=hash_password(user.password), ) db.add(new_user) @@ -59,17 +66,38 @@ def register_user(user: UserDto, db: Session = Depends(get_db)): db.add(UserPreferences(user_id=new_user.id)) db.commit() + metrics.increment("echoed_authentication_total", operation="registration", result="success") + emit_event( + "auth.registration.succeeded", + component="authentication", + actor_id=new_user.id, + actor_role=new_user.role, + organization_context=True, + result="success", + ) + return {"message": "User registered successfully", "organization_id": personal_org.id} @router.post("/auth/token", response_model=AuthTokenResponse) def login( + request: Request, form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db), ): + metrics.increment("echoed_authentication_total", operation="login", result="attempt") + enforce_rate_limit(request, "auth_login", account_identifier=form_data.username) user = authenticate_user(db, form_data.username, form_data.password) if not user: - raise HTTPException(status_code=400, detail="Incorrect username or password") + metrics.increment("echoed_authentication_total", operation="login", result="failure") + security_event( + action="authentication", + result="denied", + target_type="account", + reason="invalid_credentials", + request_id=getattr(request.state, "request_id", None), + ) + raise HTTPException(status_code=401, detail="Incorrect username or password") memberships = ( db.query(OrganizationMembership) @@ -91,6 +119,16 @@ def login( expires_delta=timedelta(minutes=120), ) + metrics.increment("echoed_authentication_total", operation="login", result="success") + emit_event( + "auth.login.succeeded", + component="authentication", + actor_id=user.id, + actor_role=user.role, + organization_context=bool(active_org_id), + result="success", + ) + return { "access_token": access_token, "token_type": "bearer", diff --git a/backend/app/api/routes/badges.py b/backend/app/api/routes/badges.py index 9737b69..ce8e6ff 100644 --- a/backend/app/api/routes/badges.py +++ b/backend/app/api/routes/badges.py @@ -5,6 +5,7 @@ from app.models import Badge, StudentBadge, User from app.schemas import BadgeCreate, BadgeResponse, StudentBadgeResponse from app.auth import get_current_user +from app.deps import require_roles router = APIRouter() @@ -13,9 +14,7 @@ def list_badges(db: Session = Depends(get_db), current_user: User = Depends(get_ return db.query(Badge).all() @router.post('/badges', response_model=BadgeResponse) -def create_badge(badge: BadgeCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - if current_user.role != 'admin': - raise HTTPException(status_code=403, detail='Admin access required') +def create_badge(badge: BadgeCreate, db: Session = Depends(get_db), current_user: User = Depends(require_roles("admin", "super_admin"))): new_badge = Badge(title=badge.title, description=badge.description, image_url=badge.image_url) db.add(new_badge) db.commit() @@ -24,15 +23,13 @@ def create_badge(badge: BadgeCreate, db: Session = Depends(get_db), current_user @router.get('/students/{student_id}/badges', response_model=list[StudentBadgeResponse]) def get_student_badges(student_id: UUID, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - if current_user.role != 'admin' and current_user.id != student_id: + if current_user.role not in {'admin', 'super_admin'} and current_user.id != student_id: raise HTTPException(status_code=403, detail='Not authorized') badges = db.query(StudentBadge).options(joinedload(StudentBadge.badge)).filter_by(student_id=student_id).all() return badges @router.post('/students/{student_id}/badges/{badge_id}', response_model=StudentBadgeResponse) -def assign_badge(student_id: UUID, badge_id: UUID, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - if current_user.role != 'admin': - raise HTTPException(status_code=403, detail='Admin access required') +def assign_badge(student_id: UUID, badge_id: UUID, db: Session = Depends(get_db), current_user: User = Depends(require_roles("admin", "super_admin"))): if not db.query(User).filter_by(id=student_id).first(): raise HTTPException(status_code=404, detail='Student not found') if not db.query(Badge).filter_by(id=badge_id).first(): diff --git a/backend/app/api/routes/courses.py b/backend/app/api/routes/courses.py index a0ee2e4..e212cbe 100644 --- a/backend/app/api/routes/courses.py +++ b/backend/app/api/routes/courses.py @@ -1,10 +1,11 @@ import uuid +import json from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, Header, HTTPException, Query, status from sqlalchemy.orm import Session, joinedload, selectinload from app.database import get_db -from app.deps import get_current_user, require_roles, require_org_roles +from app.deps import get_active_org_id, get_current_user, require_roles, require_org_roles from app.enum import MembershipStatus from app.enum import CourseVersionStatus from app.api.routes.analytics import build_course_continuation_guidance @@ -36,6 +37,21 @@ load_course_governance_summary_course, ) from app.runtime_intervention_intelligence import evaluate_runtime_intervention_recommendation +from app.course_authoring_permissions import ( + require_course_authoring_capability, + resolve_course_authoring_capabilities, +) +from app.course_authoring import ( + CourseAuthoringConflictError, + CourseAuthoringValidationError, + create_course_draft, + build_course_duplicate_payload, + serialize_course_draft, + update_course_draft, +) +from app.course_exchange import echoed_json_adapter +from app.course_templates import template_catalog, template_course +from app.observability import emit_event, metrics from app.schemas import ( CourseGovernanceSummaryResponse, CourseRuntimeInterventionRecommendationResponse, @@ -50,12 +66,137 @@ CoursePublishReadinessResponse, CourseSafePublishValidationResponse, CourseSummaryResponse, + CourseAuthoringCapabilityEnvelope, + CourseAuthoringCapabilitiesResponse, + CourseAuthoringDraftRequest, + CourseAuthoringDraftResponse, + CourseDuplicateRequest, + CourseReviewDecisionRequest, + CourseLifecycleResponse, + CourseTemplateResponse, + CourseExchangeValidationReport, + CourseImportRequest, PublishReadinessIssueResponse, RuntimeInterventionEvidenceBasisResponse, ) router = APIRouter() +def _course_studio_event(operation: str, result: str, *, actor=None, course_id=None, reason: str | None = None) -> None: + metrics.increment("echoed_course_studio_operations_total", operation=operation, result=result) + emit_event( + f"course.{operation}.{result}", + component="course_studio", + operation=operation, + result=result, + actor_id=getattr(actor, "id", None), + actor_role=getattr(actor, "role", None), + resource_type="course", + resource_id=course_id, + reason=reason, + ) + + +def _load_authoring_course(db: Session, course_id: uuid.UUID) -> Course | None: + return ( + db.query(Course) + .options( + selectinload(Course.units).selectinload(Unit.lessons).selectinload(Lesson.activities), + selectinload(Course.units).selectinload(Unit.lessons).selectinload(Lesson.sources), + selectinload(Course.units) + .selectinload(Unit.lessons) + .selectinload(Lesson.activities) + .selectinload(Activity.storybook_pages), + selectinload(Course.assessments), + selectinload(Course.versions), + ) + .filter(Course.id == course_id) + .first() + ) + + +def _authoring_validation_http_error(exc: CourseAuthoringValidationError) -> HTTPException: + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"code": "course_authoring_validation_failed", "issues": exc.issues}, + ) + + +@router.get("/course-authoring/templates", response_model=list[CourseTemplateResponse]) +def list_course_authoring_templates( + active_org_id: uuid.UUID | None = Depends(get_active_org_id), + db: Session = Depends(get_db), + current_user=Depends(get_current_user), +): + capabilities = resolve_course_authoring_capabilities(db, current_user, active_organization_id=active_org_id) + require_course_authoring_capability(capabilities, "create") + _course_studio_event("template_list", "success", actor=current_user) + return template_catalog() + + +@router.post("/course-authoring/import-validation", response_model=CourseExchangeValidationReport) +def validate_course_import( + request: CourseImportRequest, + active_org_id: uuid.UUID | None = Depends(get_active_org_id), + db: Session = Depends(get_db), + current_user=Depends(get_current_user), +): + capabilities = resolve_course_authoring_capabilities(db, current_user, active_organization_id=active_org_id) + require_course_authoring_capability(capabilities, "create") + issues = echoed_json_adapter.validate_import(request.document) + result = "failure" if any(issue.severity == "blocking" for issue in issues) else "success" + _course_studio_event("import_validation", result, actor=current_user, reason="blocking_issue" if result == "failure" else None) + return { + "format": request.document.get("format", "unknown"), + "can_import": not any(issue.severity == "blocking" for issue in issues), + "issues": [issue.__dict__ for issue in issues], + } + + +@router.get( + "/course-authoring/capabilities", + response_model=CourseAuthoringCapabilityEnvelope, +) +def get_course_authoring_collection_capabilities( + active_org_id: uuid.UUID | None = Depends(get_active_org_id), + db: Session = Depends(get_db), + current_user=Depends(get_current_user), +): + capabilities = resolve_course_authoring_capabilities( + db, + current_user, + active_organization_id=active_org_id, + ) + return CourseAuthoringCapabilityEnvelope( + organization_id=active_org_id, + capabilities=CourseAuthoringCapabilitiesResponse(**capabilities.model_payload()), + ) + + +@router.get( + "/courses/{course_id}/authoring-capabilities", + response_model=CourseAuthoringCapabilityEnvelope, +) +def get_course_authoring_course_capabilities( + course_id: str, + db: Session = Depends(get_db), + current_user=Depends(get_current_user), +): + try: + parsed_course_id = uuid.UUID(course_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid course id") from exc + course = db.query(Course).filter(Course.id == parsed_course_id).first() + if course is None: + raise HTTPException(status_code=404, detail="Course not found") + capabilities = resolve_course_authoring_capabilities(db, current_user, course=course) + return CourseAuthoringCapabilityEnvelope( + organization_id=course.organization_id, + course_id=course.id, + capabilities=CourseAuthoringCapabilitiesResponse(**capabilities.model_payload()), + ) + + def _can_view_course_publish_readiness(db: Session, current_user, course: Course) -> bool: if current_user.role in {"admin", "teacher"}: return True @@ -141,10 +282,21 @@ def get_courses( age_max: int | None = Query(default=None), locale: str | None = Query(default=None), org_id: str | None = Query(default=None), + active_org_id: uuid.UUID | None = Depends(get_active_org_id), db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher", "student")), + current_user=Depends(get_current_user), ): query = db.query(Course) + platform_role = getattr(current_user.role, "value", current_user.role) + if platform_role not in {"admin", "super_admin", "teacher", "student"}: + capabilities = resolve_course_authoring_capabilities( + db, + current_user, + active_organization_id=active_org_id, + ) + if not (capabilities.can_create or capabilities.can_duplicate or capabilities.can_view_draft): + raise HTTPException(status_code=403, detail="Not authorized") + query = query.filter(Course.organization_id == active_org_id) if subject: query = query.filter(Course.subject == subject) if age_min is not None: @@ -154,6 +306,10 @@ def get_courses( if locale: query = query.filter(Course.default_locale == locale) if org_id: + if platform_role not in {"admin", "super_admin"} and ( + active_org_id is None or str(active_org_id) != org_id + ): + raise HTTPException(status_code=403, detail="Not authorized for the requested organization") query = query.filter(Course.organization_id == org_id) return [_serialize_course_summary(course) for course in query.all()] @@ -516,143 +672,316 @@ def get_course_runtime_intervention_recommendations( @router.post("/courses") def create_course( course: CourseDto, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + active_org_id: uuid.UUID | None = Depends(get_active_org_id), db: Session = Depends(get_db), current_user=Depends(get_current_user), ): - if current_user.role not in ["admin", "teacher"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized" + capabilities = resolve_course_authoring_capabilities( + db, current_user, active_organization_id=active_org_id + ) + require_course_authoring_capability(capabilities, "create") + payload = CourseAuthoringDraftRequest.model_validate(course.model_dump()) + try: + create_course_draft( + db, + payload, + current_user_id=current_user.id, + organization_id=active_org_id, + idempotency_key=idempotency_key or f"legacy-{uuid.uuid4()}", ) - new_course = Course(title=course.title, description=course.description) - new_course.learning_objectives = course.learning_objectives - new_course.skill_tags = course.skill_tags - new_course.standards_metadata = course.standards_metadata - db.add(new_course) - db.flush() - - for unit_data in course.units: - new_unit = Unit( - title=unit_data.title, - content=unit_data.content, - order=unit_data.order, - course_id=new_course.id, + db.commit() + _course_studio_event("draft_create", "success", actor=current_user) + except CourseAuthoringValidationError as exc: + db.rollback() + _course_studio_event("draft_create", "failure", actor=current_user, reason="validation") + raise _authoring_validation_http_error(exc) from exc + except Exception: + db.rollback() + _course_studio_event("draft_create", "failure", actor=current_user, reason="unexpected") + raise + return {"message": "Course draft created successfully."} + + +@router.post("/courses/authoring", response_model=CourseAuthoringDraftResponse) +def create_course_authoring( + course: CourseAuthoringDraftRequest, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + active_org_id: uuid.UUID | None = Depends(get_active_org_id), + db: Session = Depends(get_db), + current_user=Depends(get_current_user), +): + capabilities = resolve_course_authoring_capabilities( + db, + current_user, + active_organization_id=active_org_id, + ) + require_course_authoring_capability(capabilities, "create") + if course.template_id and not course.units: + template_payload = template_course(course.template_id) + if template_payload is None: + raise HTTPException(status_code=422, detail={"code": "unknown_course_template"}) + merged = dict(template_payload) + merged.update(course.model_dump(exclude={"units"}, exclude_none=True)) + merged["units"] = template_payload.get("units", []) + course = CourseAuthoringDraftRequest.model_validate(merged) + try: + new_course = create_course_draft( + db, + course, + current_user_id=current_user.id, + organization_id=active_org_id, + idempotency_key=idempotency_key or f"compat-{uuid.uuid4()}", ) - db.add(new_unit) - db.flush() - - for lesson_data in unit_data.lessons: - readiness = evaluate_lesson_readiness( - title=lesson_data.title, - objective=lesson_data.objective, - learning_objectives=lesson_data.learning_objectives, - key_concepts=lesson_data.key_concepts, - hook=lesson_data.hook, - content=lesson_data.content, - guided_practice=lesson_data.guided_practice, - independent_practice=lesson_data.independent_practice, - assessment=lesson_data.assessment, - sources=lesson_data.sources, - ) - review_status, reviewed_by = resolve_review_fields( - db=db, - current_user=current_user, - requested_status=lesson_data.review_status, - unit_id=new_unit.id, - readiness=readiness, - ) - new_lesson = Lesson( - title=lesson_data.title, - objective=lesson_data.objective, - learning_objectives=lesson_data.learning_objectives, - key_concepts=lesson_data.key_concepts, - teacher_notes=lesson_data.teacher_notes, - discussion_questions=lesson_data.discussion_questions, - hook=lesson_data.hook, - content=lesson_data.content, - guided_practice=lesson_data.guided_practice, - independent_practice=lesson_data.independent_practice, - assessment=lesson_data.assessment, - review_status=review_status, - reviewed_by=reviewed_by, - skill_tags=lesson_data.skill_tags, - standards_metadata=lesson_data.standards_metadata, - order=lesson_data.order, - duration_minutes=lesson_data.duration_minutes, - unit_id=new_unit.id, - ) - db.add(new_lesson) - db.flush() - - for source_data in lesson_data.sources: - db.add( - Source( - lesson_id=new_lesson.id, - citation=source_data.citation, - url=source_data.url, - ) - ) - - for activity_data in lesson_data.activities: - new_activity = Activity( - type=activity_data.type, - title=activity_data.title, - content=activity_data.content, - order=activity_data.order, - lesson_id=new_lesson.id, - ) - db.add(new_activity) - db.flush() - - if activity_data.type == "storybook": - for page in getattr(activity_data, "pages", []): - db.add( - StorybookPage( - activity_id=new_activity.id, - image_url=page.image_url, - order=page.order, - ) - ) + db.commit() + loaded = _load_authoring_course(db, new_course.id) + _course_studio_event("draft_create", "success", actor=current_user, course_id=new_course.id) + return serialize_course_draft(db, loaded, capabilities) + except CourseAuthoringValidationError as exc: + db.rollback() + _course_studio_event("draft_create", "failure", actor=current_user, reason="validation") + raise _authoring_validation_http_error(exc) from exc + except Exception: + db.rollback() + _course_studio_event("draft_create", "failure", actor=current_user, reason="unexpected") + raise - db.commit() - return { - "message": "Course with units, lessons, and activities created successfully!" - } +@router.get("/courses/{course_id}/authoring-preview", response_model=CourseResponse) +def preview_course_authoring_draft( + course_id: str, + db: Session = Depends(get_db), + current_user=Depends(get_current_user), +): + try: + parsed_course_id = uuid.UUID(course_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid course id") from exc + course = _load_authoring_course(db, parsed_course_id) + if course is None: + raise HTTPException(status_code=404, detail="Course not found") + capabilities = resolve_course_authoring_capabilities(db, current_user, course=course) + require_course_authoring_capability(capabilities, "preview") + try: + response = serialize_course(course, viewer_role="student", learner_preview=True) + except Exception: + _course_studio_event("preview", "failure", actor=current_user, course_id=course.id, reason="unexpected") + raise + _course_studio_event("preview", "success", actor=current_user, course_id=course.id) + return response -@router.post("/courses/authoring", response_model=CourseSummaryResponse) -def create_course_authoring( - course: CourseCreateRequest, +@router.get("/courses/{course_id}/export") +def export_course_authoring_document( + course_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user), - membership=Depends(require_org_roles("content_admin", "org_admin")), ): - new_course = Course( - title=course.title, - description=course.description, - subject=course.subject, - age_band_min=course.age_band_min, - age_band_max=course.age_band_max, - default_locale=course.default_locale, - learning_objectives=course.learning_objectives, - skill_tags=course.skill_tags, - standards_metadata=course.standards_metadata, - created_by=current_user.id, - organization_id=membership.organization_id, - ) - db.add(new_course) - db.flush() + try: + parsed_course_id = uuid.UUID(course_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid course id") from exc + course = _load_authoring_course(db, parsed_course_id) + if course is None: + raise HTTPException(status_code=404, detail="Course not found") + capabilities = resolve_course_authoring_capabilities(db, current_user, course=course) + require_course_authoring_capability(capabilities, "view_draft") + draft = serialize_course_draft(db, course, capabilities) + for key in ("capabilities", "organization_id", "created_by"): + draft.pop(key, None) + return echoed_json_adapter.export(json.loads(json.dumps(draft, default=str))) - version = CourseVersion( - course_id=new_course.id, - version_number=1, - status=CourseVersionStatus.DRAFT, - changelog="Initial draft", - ) - db.add(version) + +@router.post("/courses/{course_id}/submit-review", response_model=CourseLifecycleResponse) +def submit_course_for_review( + course_id: str, + db: Session = Depends(get_db), + current_user=Depends(get_current_user), +): + try: + parsed_course_id = uuid.UUID(course_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid course id") from exc + course = _load_authoring_course(db, parsed_course_id) + if course is None: + raise HTTPException(status_code=404, detail="Course not found") + capabilities = resolve_course_authoring_capabilities(db, current_user, course=course) + require_course_authoring_capability(capabilities, "submit_review") + submission_issues = [] + if not course.units: + submission_issues.append({"entity_type": "course", "entity_id": str(course.id), "entity_title": course.title, "code": "course_has_no_units", "message": "Add at least one unit before review."}) + for unit in course.units: + if not unit.lessons: + submission_issues.append({"entity_type": "unit", "entity_id": str(unit.id), "entity_title": unit.title, "code": "unit_has_no_lessons", "message": "Add at least one lesson before review."}) + if submission_issues: + _course_studio_event("review_submit", "failure", actor=current_user, course_id=course.id, reason="validation") + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": "course_not_ready_for_review", + "issues": submission_issues, + }, + ) + changed_at = datetime.utcnow() + metadata = dict(course.revision_metadata or {}) + metadata.update({"authoring_state": "submitted", "submitted_by": str(current_user.id), "submitted_at": changed_at.isoformat()}) + course.revision_metadata = metadata + course.updated_at = changed_at db.commit() - db.refresh(new_course) - return new_course + _course_studio_event("review_submit", "success", actor=current_user, course_id=course.id) + return CourseLifecycleResponse(course_id=course.id, lifecycle_state="submitted", revision_number=course.revision_number, changed_at=changed_at) + + +@router.post("/courses/{course_id}/review", response_model=CourseLifecycleResponse) +def review_course_authoring_draft( + course_id: str, + payload: CourseReviewDecisionRequest, + db: Session = Depends(get_db), + current_user=Depends(get_current_user), +): + try: + parsed_course_id = uuid.UUID(course_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid course id") from exc + course = _load_authoring_course(db, parsed_course_id) + if course is None: + raise HTTPException(status_code=404, detail="Course not found") + capabilities = resolve_course_authoring_capabilities(db, current_user, course=course) + require_course_authoring_capability(capabilities, "review") + if (course.revision_metadata or {}).get("authoring_state") != "submitted": + _course_studio_event("review_transition", "failure", actor=current_user, course_id=course.id, reason="state_conflict") + raise HTTPException(status_code=409, detail="Only a submitted course can be reviewed") + changed_at = datetime.utcnow() + metadata = dict(course.revision_metadata or {}) + metadata.update({"authoring_state": payload.decision, "review_feedback": payload.feedback, "reviewed_by": str(current_user.id), "reviewed_at": changed_at.isoformat()}) + course.revision_metadata = metadata + course.updated_at = changed_at + if payload.decision == "approved": + for unit in course.units: + for lesson in unit.lessons: + lesson.review_status = "approved" + lesson.reviewed_by = current_user.id + db.commit() + _course_studio_event("review_transition", "success", actor=current_user, course_id=course.id) + return CourseLifecycleResponse(course_id=course.id, lifecycle_state=payload.decision, revision_number=course.revision_number, feedback=payload.feedback, changed_at=changed_at) + + +@router.get( + "/courses/{course_id}/authoring-draft", + response_model=CourseAuthoringDraftResponse, +) +def get_course_authoring_draft( + course_id: str, + db: Session = Depends(get_db), + current_user=Depends(get_current_user), +): + try: + parsed_course_id = uuid.UUID(course_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid course id") from exc + course = _load_authoring_course(db, parsed_course_id) + if course is None: + raise HTTPException(status_code=404, detail="Course not found") + capabilities = resolve_course_authoring_capabilities(db, current_user, course=course) + require_course_authoring_capability(capabilities, "view_draft") + return serialize_course_draft(db, course, capabilities) + + +@router.put( + "/courses/{course_id}/authoring-draft", + response_model=CourseAuthoringDraftResponse, +) +def save_course_authoring_draft( + course_id: str, + course_payload: CourseAuthoringDraftRequest, + db: Session = Depends(get_db), + current_user=Depends(get_current_user), +): + try: + parsed_course_id = uuid.UUID(course_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid course id") from exc + course = _load_authoring_course(db, parsed_course_id) + if course is None: + raise HTTPException(status_code=404, detail="Course not found") + capabilities = resolve_course_authoring_capabilities(db, current_user, course=course) + require_course_authoring_capability(capabilities, "edit") + try: + update_course_draft(db, course, course_payload) + db.commit() + loaded = _load_authoring_course(db, course.id) + _course_studio_event("draft_save", "success", actor=current_user, course_id=course.id) + return serialize_course_draft(db, loaded, capabilities) + except CourseAuthoringConflictError as exc: + db.rollback() + _course_studio_event("draft_save", "conflict", actor=current_user, course_id=course.id, reason="revision_conflict") + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "course_authoring_revision_conflict", + "course_id": str(exc.course_id), + "current_revision": exc.current_revision, + "updated_at": exc.updated_at.isoformat() if exc.updated_at else None, + }, + ) from exc + except CourseAuthoringValidationError as exc: + db.rollback() + _course_studio_event("draft_save", "failure", actor=current_user, course_id=course.id, reason="validation") + raise _authoring_validation_http_error(exc) from exc + except Exception: + db.rollback() + _course_studio_event("draft_save", "failure", actor=current_user, course_id=course.id, reason="unexpected") + raise + + +@router.post( + "/courses/{course_id}/duplicate", + response_model=CourseAuthoringDraftResponse, +) +def duplicate_course_for_authoring( + course_id: str, + request: CourseDuplicateRequest, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + db: Session = Depends(get_db), + current_user=Depends(get_current_user), +): + try: + parsed_course_id = uuid.UUID(course_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid course id") from exc + source_course = _load_authoring_course(db, parsed_course_id) + if source_course is None: + raise HTTPException(status_code=404, detail="Course not found") + source_capabilities = resolve_course_authoring_capabilities(db, current_user, course=source_course) + require_course_authoring_capability(source_capabilities, "duplicate") + duplicate_payload = build_course_duplicate_payload(source_course) + if request.title and request.title.strip(): + duplicate_payload["title"] = request.title.strip() + payload = CourseAuthoringDraftRequest.model_validate(duplicate_payload) + try: + duplicate = create_course_draft( + db, + payload, + current_user_id=current_user.id, + organization_id=source_course.organization_id, + idempotency_key=idempotency_key or f"duplicate-{uuid.uuid4()}", + ) + metadata = dict(duplicate.revision_metadata or {}) + metadata["derivative_source_course_id"] = str(source_course.id) + duplicate.revision_metadata = metadata + db.commit() + loaded = _load_authoring_course(db, duplicate.id) + capabilities = resolve_course_authoring_capabilities(db, current_user, course=loaded) + _course_studio_event("duplicate", "success", actor=current_user, course_id=duplicate.id) + return serialize_course_draft(db, loaded, capabilities) + except CourseAuthoringValidationError as exc: + db.rollback() + _course_studio_event("duplicate", "failure", actor=current_user, reason="validation") + raise _authoring_validation_http_error(exc) from exc + except Exception: + db.rollback() + _course_studio_event("duplicate", "failure", actor=current_user, reason="unexpected") + raise @router.post("/courses/{course_id}/versions", response_model=CourseVersionResponse) @@ -660,11 +989,17 @@ def create_course_version( course_id: str, payload: CourseVersionCreateRequest, db: Session = Depends(get_db), - membership=Depends(require_org_roles("content_admin", "org_admin")), + current_user=Depends(get_current_user), ): - course = db.query(Course).filter(Course.id == course_id).first() + try: + parsed_course_id = uuid.UUID(course_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid course id") from exc + course = db.query(Course).filter(Course.id == parsed_course_id).first() if not course: raise HTTPException(status_code=404, detail="Course not found") + capabilities = resolve_course_authoring_capabilities(db, current_user, course=course) + require_course_authoring_capability(capabilities, "edit") latest_version = ( db.query(CourseVersion) @@ -685,16 +1020,45 @@ def create_course_version( return new_version +@router.get("/courses/{course_id}/versions", response_model=list[CourseVersionResponse]) +def list_course_versions( + course_id: str, + db: Session = Depends(get_db), + current_user=Depends(get_current_user), +): + try: + parsed_course_id = uuid.UUID(course_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid course id") from exc + course = db.query(Course).filter(Course.id == parsed_course_id).first() + if not course: + raise HTTPException(status_code=404, detail="Course not found") + capabilities = resolve_course_authoring_capabilities(db, current_user, course=course) + require_course_authoring_capability(capabilities, "view_draft") + return ( + db.query(CourseVersion) + .filter(CourseVersion.course_id == course.id) + .order_by(CourseVersion.version_number.desc()) + .all() + ) + + @router.put("/course-versions/{version_id}", response_model=CourseVersionResponse) def update_course_version( version_id: str, payload: CourseVersionCreateRequest, db: Session = Depends(get_db), - membership=Depends(require_org_roles("content_admin", "org_admin")), + current_user=Depends(get_current_user), ): - version = db.query(CourseVersion).filter(CourseVersion.id == version_id).first() + try: + parsed_version_id = uuid.UUID(version_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid course version id") from exc + version = db.query(CourseVersion).filter(CourseVersion.id == parsed_version_id).first() if not version: raise HTTPException(status_code=404, detail="Course version not found") + capabilities = resolve_course_authoring_capabilities(db, current_user, course=version.course) + require_course_authoring_capability(capabilities, "edit") if version.status != CourseVersionStatus.DRAFT: raise HTTPException(status_code=400, detail="Only drafts can be edited") @@ -709,19 +1073,61 @@ def publish_course_version( version_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user), - membership=Depends(require_org_roles("content_admin", "org_admin")), ): - version = db.query(CourseVersion).filter(CourseVersion.id == version_id).first() + _course_studio_event("publish", "attempt", actor=current_user) + try: + parsed_version_id = uuid.UUID(version_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid course version id") from exc + version = db.query(CourseVersion).filter(CourseVersion.id == parsed_version_id).first() if not version: raise HTTPException(status_code=404, detail="Course version not found") if version.status != CourseVersionStatus.DRAFT: raise HTTPException(status_code=400, detail="Version is not a draft") + course = _load_authoring_course(db, version.course_id) + capabilities = resolve_course_authoring_capabilities(db, current_user, course=course) + require_course_authoring_capability(capabilities, "publish") + if (course.revision_metadata or {}).get("authoring_state") != "approved": + _course_studio_event("publish", "failure", actor=current_user, course_id=course.id, reason="approval_required") + raise HTTPException(status_code=409, detail="Course requires independent approval before publishing") + validation = evaluate_course_safe_publish(course) + if validation.blocking_issues: + _course_studio_event("publish", "failure", actor=current_user, course_id=course.id, reason="safe_publish_blocked") + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": "course_safe_publish_blocked", + "issues": [_serialize_publish_readiness_issue(issue).model_dump(mode="json") for issue in validation.blocking_issues], + }, + ) + + changed_at = datetime.utcnow() + for other_version in course.versions: + if other_version.id != version.id and other_version.status == CourseVersionStatus.PUBLISHED: + other_version.status = CourseVersionStatus.ARCHIVED version.status = CourseVersionStatus.PUBLISHED - version.published_at = datetime.utcnow() + version.published_at = changed_at version.published_by = current_user.id + course.revision_status = "current" + course.published_at = changed_at + snapshot = serialize_course_draft(db, course, capabilities) + metadata = dict(course.revision_metadata or {}) + metadata.update({ + "authoring_state": "published", + "published_version_id": str(version.id), + "published_snapshot": json.loads(json.dumps(snapshot, default=str)), + }) + course.revision_metadata = metadata + for unit in course.units: + unit.revision_status = "current" + unit.published_at = changed_at + for lesson in unit.lessons: + lesson.revision_status = "current" + lesson.published_at = changed_at db.commit() db.refresh(version) + _course_studio_event("publish", "success", actor=current_user, course_id=course.id) return version @@ -732,118 +1138,38 @@ def update_course( db: Session = Depends(get_db), current_user=Depends(get_current_user), ): - if current_user.role not in ["admin", "teacher"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized" - ) try: cid = uuid.UUID(course_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid course id") - existing_course = db.query(Course).filter(Course.id == cid).first() + existing_course = _load_authoring_course(db, cid) if not existing_course: raise HTTPException(status_code=404, detail="Course not found") - + capabilities = resolve_course_authoring_capabilities(db, current_user, course=existing_course) + require_course_authoring_capability(capabilities, "edit") + payload = CourseAuthoringDraftRequest( + title=course_dto.title, + description=course_dto.description, + subject=existing_course.subject, + age_band_min=existing_course.age_band_min, + age_band_max=existing_course.age_band_max, + default_locale=existing_course.default_locale, + learning_objectives=course_dto.learning_objectives, + skill_tags=course_dto.skill_tags, + standards_metadata=course_dto.standards_metadata, + revision_number=existing_course.revision_number, + units=course_dto.model_dump()["units"], + ) try: - existing_course.title = course_dto.title - existing_course.description = course_dto.description - existing_course.learning_objectives = course_dto.learning_objectives - existing_course.skill_tags = course_dto.skill_tags - existing_course.standards_metadata = course_dto.standards_metadata - - for unit in list(existing_course.units): - db.delete(unit) - db.flush() - - for unit_dto in course_dto.units: - new_unit = Unit( - title=unit_dto.title, - content=unit_dto.content, - order=unit_dto.order, - course_id=existing_course.id, - ) - db.add(new_unit) - db.flush() - - for lesson_dto in unit_dto.lessons: - readiness = evaluate_lesson_readiness( - title=lesson_dto.title, - objective=lesson_dto.objective, - learning_objectives=lesson_dto.learning_objectives, - key_concepts=lesson_dto.key_concepts, - hook=lesson_dto.hook, - content=lesson_dto.content, - guided_practice=lesson_dto.guided_practice, - independent_practice=lesson_dto.independent_practice, - assessment=lesson_dto.assessment, - sources=lesson_dto.sources, - ) - review_status, reviewed_by = resolve_review_fields( - db=db, - current_user=current_user, - requested_status=lesson_dto.review_status, - unit_id=new_unit.id, - readiness=readiness, - ) - new_lesson = Lesson( - title=lesson_dto.title, - objective=lesson_dto.objective, - learning_objectives=lesson_dto.learning_objectives, - key_concepts=lesson_dto.key_concepts, - teacher_notes=lesson_dto.teacher_notes, - discussion_questions=lesson_dto.discussion_questions, - hook=lesson_dto.hook, - content=lesson_dto.content, - guided_practice=lesson_dto.guided_practice, - independent_practice=lesson_dto.independent_practice, - assessment=lesson_dto.assessment, - review_status=review_status, - reviewed_by=reviewed_by, - skill_tags=lesson_dto.skill_tags, - standards_metadata=lesson_dto.standards_metadata, - order=lesson_dto.order, - duration_minutes=lesson_dto.duration_minutes, - unit_id=new_unit.id, - ) - db.add(new_lesson) - db.flush() - - for source_data in lesson_dto.sources: - db.add( - Source( - lesson_id=new_lesson.id, - citation=source_data.citation, - url=source_data.url, - ) - ) - - for activity_dto in lesson_dto.activities: - new_activity = Activity( - title=activity_dto.title, - type=activity_dto.type, - content=activity_dto.content, - order=activity_dto.order, - lesson_id=new_lesson.id, - ) - db.add(new_activity) - db.flush() - - if activity_dto.type == "storybook": - for page in getattr(activity_dto, "pages", []): - db.add( - StorybookPage( - activity_id=new_activity.id, - image_url=page.image_url, - order=page.order, - ) - ) - + update_course_draft(db, existing_course, payload) db.commit() - db.refresh(existing_course) - except Exception as e: + except CourseAuthoringValidationError as exc: db.rollback() - raise e + raise _authoring_validation_http_error(exc) from exc + except Exception: + db.rollback() + raise return {"message": "Course updated successfully"} @@ -854,10 +1180,6 @@ def delete_course( db: Session = Depends(get_db), current_user=Depends(get_current_user), ): - if current_user.role not in ["admin", "teacher"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized" - ) try: cid = uuid.UUID(course_id) except ValueError: @@ -865,6 +1187,8 @@ def delete_course( course = db.query(Course).filter(Course.id == cid).first() if not course: raise HTTPException(status_code=404, detail="Course not found") + capabilities = resolve_course_authoring_capabilities(db, current_user, course=course) + require_course_authoring_capability(capabilities, "edit") db.delete(course) db.commit() diff --git a/backend/app/api/routes/invites.py b/backend/app/api/routes/invites.py index c588200..c721814 100644 --- a/backend/app/api/routes/invites.py +++ b/backend/app/api/routes/invites.py @@ -1,30 +1,36 @@ from datetime import datetime, timedelta import uuid -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.orm import Session from app.database import get_db from app.deps import get_current_user, require_org_roles -from app.enum import OrganizationRole +from app.enum import MembershipStatus, OrganizationRole from app.models import OrganizationInvite, OrganizationMembership, Organization from app.schemas import ( OrganizationInviteCreate, + OrganizationInviteCreatedResponse, OrganizationInviteResponse, InviteAcceptRequest, ) router = APIRouter() +from app.rate_limit import enforce_rate_limit +from app.security import ORG_ADMIN_GRANTABLE_ROLES, security_event -@router.post("/orgs/{org_id}/invites", response_model=OrganizationInviteResponse) + +@router.post("/orgs/{org_id}/invites", response_model=OrganizationInviteCreatedResponse) def create_invite( org_id: str, payload: OrganizationInviteCreate, + request: Request, db: Session = Depends(get_db), current_user=Depends(get_current_user), membership=Depends(require_org_roles("org_admin")), ): + enforce_rate_limit(request, "invite_manage", actor_id=current_user.id) try: org_uuid = uuid.UUID(org_id) except ValueError as exc: @@ -35,6 +41,11 @@ def create_invite( if str(membership.organization_id) != str(org_id): raise HTTPException(status_code=403, detail="Organization mismatch") + if payload.role not in ORG_ADMIN_GRANTABLE_ROLES: + raise HTTPException( + status_code=403, + detail="This organization role cannot be granted through invitations.", + ) expires_at = payload.expires_at or (datetime.utcnow() + timedelta(days=7)) invite = OrganizationInvite( organization_id=org_uuid, @@ -47,6 +58,15 @@ def create_invite( db.add(invite) db.commit() db.refresh(invite) + security_event( + action="organization_invite_create", + result="allowed", + actor_id=current_user.id, + target_type="organization", + target_id=org_uuid, + reason=f"role_{payload.role}", + request_id=getattr(request.state, "request_id", None), + ) return invite @@ -72,27 +92,45 @@ def list_invites( @router.post("/invites/accept") def accept_invite( payload: InviteAcceptRequest, + request: Request, db: Session = Depends(get_db), current_user=Depends(get_current_user), ): + enforce_rate_limit(request, "invite_accept", actor_id=current_user.id) invite = ( db.query(OrganizationInvite) .filter(OrganizationInvite.token == payload.token) .first() ) if not invite: - raise HTTPException(status_code=404, detail="Invite not found") + raise HTTPException(status_code=404, detail="Invitation is invalid or unavailable") if invite.accepted_at: - raise HTTPException(status_code=400, detail="Invite already accepted") + raise HTTPException(status_code=409, detail="Invitation is invalid or unavailable") if invite.expires_at < datetime.utcnow(): - raise HTTPException(status_code=400, detail="Invite expired") + raise HTTPException(status_code=409, detail="Invitation is invalid or unavailable") - membership = OrganizationMembership( - organization_id=invite.organization_id, - user_id=current_user.id, - role=invite.role, + existing_membership = ( + db.query(OrganizationMembership) + .filter( + OrganizationMembership.organization_id == invite.organization_id, + OrganizationMembership.user_id == current_user.id, + ) + .first() ) + if existing_membership: + existing_membership.role = invite.role + existing_membership.status = MembershipStatus.ACTIVE + membership = existing_membership + else: + membership = OrganizationMembership( + organization_id=invite.organization_id, + user_id=current_user.id, + role=invite.role, + status=MembershipStatus.ACTIVE, + ) + invite.accepted_at = datetime.utcnow() - db.add(membership) + if not existing_membership: + db.add(membership) db.commit() return {"message": "Invite accepted"} diff --git a/backend/app/api/routes/lesson_sessions.py b/backend/app/api/routes/lesson_sessions.py index 6cd00ba..3275451 100644 --- a/backend/app/api/routes/lesson_sessions.py +++ b/backend/app/api/routes/lesson_sessions.py @@ -1,11 +1,13 @@ from datetime import datetime +from uuid import UUID from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from app.database import get_db from app.deps import get_current_user, require_org_roles -from app.models import LessonSession +from app.models import LessonSession, Section from app.schemas import LessonSessionResponse, LessonSessionEndRequest +from app.section_scope import require_scoped_section, require_section_lesson router = APIRouter() @@ -13,14 +15,16 @@ @router.post("/sections/{section_id}/lessons/{lesson_id}/start", response_model=LessonSessionResponse) def start_lesson_session( section_id: str, - lesson_id: str, + lesson_id: UUID, db: Session = Depends(get_db), current_user=Depends(get_current_user), membership=Depends(require_org_roles("teacher", "org_admin", "instructor")), ): + section = require_scoped_section(db, membership, section_id) + lesson = require_section_lesson(db, section, lesson_id) session = LessonSession( - section_id=section_id, - lesson_id=lesson_id, + section_id=section.id, + lesson_id=lesson.id, started_by=current_user.id, ) db.add(session) @@ -31,12 +35,20 @@ def start_lesson_session( @router.post("/lesson-sessions/{session_id}/end", response_model=LessonSessionResponse) def end_lesson_session( - session_id: str, + session_id: UUID, payload: LessonSessionEndRequest, db: Session = Depends(get_db), membership=Depends(require_org_roles("teacher", "org_admin", "instructor")), ): - session = db.query(LessonSession).filter(LessonSession.id == session_id).first() + session = ( + db.query(LessonSession) + .join(Section, Section.id == LessonSession.section_id) + .filter( + LessonSession.id == session_id, + Section.organization_id == membership.organization_id, + ) + .first() + ) if not session: raise HTTPException(status_code=404, detail="Session not found") session.ended_at = payload.ended_at or datetime.utcnow() diff --git a/backend/app/api/routes/lessons.py b/backend/app/api/routes/lessons.py index 5a6d6ef..d841870 100644 --- a/backend/app/api/routes/lessons.py +++ b/backend/app/api/routes/lessons.py @@ -12,6 +12,7 @@ serialize_lesson, ) from app.models import Lesson, Source +from app.content_scope import course_for_lesson, course_for_unit, require_course_edit from app.schemas import LessonResponse from pydantic import BaseModel, Field from typing import Optional @@ -73,8 +74,9 @@ def _lesson_readiness_for_payload(lesson: LessonCreate) -> object: def create_lesson( lesson: LessonCreate, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin")), ): + require_course_edit(db, current_user, course_for_unit(db, lesson.unit_id)) readiness = _lesson_readiness_for_payload(lesson) review_status, reviewed_by = resolve_review_fields( db=db, @@ -112,7 +114,7 @@ def create_lesson( @router.get('/lessons', response_model=list[LessonResponse]) def list_lessons( db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin")), ): return [ serialize_lesson(lesson, viewer_role=current_user.role) @@ -123,7 +125,7 @@ def list_lessons( def get_lesson( lesson_id: UUID, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher", "student")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin", "student")), ): lesson = db.query(Lesson).filter_by(id=lesson_id).first() if not lesson: @@ -140,11 +142,13 @@ def update_lesson( lesson_id: UUID, lesson: LessonUpdate, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin")), ): db_lesson = db.query(Lesson).filter_by(id=lesson_id).first() if not db_lesson: raise HTTPException(status_code=404, detail='Lesson not found') + require_course_edit(db, current_user, course_for_lesson(db, db_lesson.id)) + require_course_edit(db, current_user, course_for_unit(db, lesson.unit_id)) readiness = _lesson_readiness_for_payload(lesson) review_status, reviewed_by = resolve_review_fields( db=db, @@ -181,11 +185,12 @@ def update_lesson( def delete_lesson( lesson_id: UUID, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin")), ): db_lesson = db.query(Lesson).filter_by(id=lesson_id).first() if not db_lesson: raise HTTPException(status_code=404, detail='Lesson not found') + require_course_edit(db, current_user, course_for_lesson(db, db_lesson.id)) db.delete(db_lesson) db.commit() return {'message': 'Lesson deleted'} diff --git a/backend/app/api/routes/orgs.py b/backend/app/api/routes/orgs.py index b2e65bb..9afd958 100644 --- a/backend/app/api/routes/orgs.py +++ b/backend/app/api/routes/orgs.py @@ -6,7 +6,7 @@ from app.auth import ActiveOrganizationContext, create_access_token from app.database import get_db from app.deps import get_current_user -from app.enum import OrganizationType, OrganizationRole +from app.enum import MembershipStatus, OrganizationType, OrganizationRole from app.models import Enrollment, Organization, OrganizationMembership, Section, User from app.schemas import ( OrganizationCreate, @@ -38,7 +38,10 @@ def list_orgs( ): memberships = ( db.query(OrganizationMembership) - .filter(OrganizationMembership.user_id == current_user.id) + .filter( + OrganizationMembership.user_id == current_user.id, + OrganizationMembership.status == MembershipStatus.ACTIVE, + ) .all() ) org_ids = [membership.organization_id for membership in memberships] @@ -167,6 +170,7 @@ def update_org( .filter( OrganizationMembership.organization_id == org_uuid, OrganizationMembership.user_id == current_user.id, + OrganizationMembership.status == MembershipStatus.ACTIVE, ) .first() ) @@ -201,6 +205,7 @@ def switch_org( .filter( OrganizationMembership.organization_id == org_uuid, OrganizationMembership.user_id == current_user.id, + OrganizationMembership.status == MembershipStatus.ACTIVE, ) .first() ) diff --git a/backend/app/api/routes/posts.py b/backend/app/api/routes/posts.py index b344ea2..47f96ec 100644 --- a/backend/app/api/routes/posts.py +++ b/backend/app/api/routes/posts.py @@ -1,25 +1,37 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.orm import Session from uuid import UUID from app.database import get_db -from app.models import Post +from app.deps import get_current_user +from app.models import Post, Thread, User +from app.rate_limit import enforce_rate_limit +from app.security import require_owner_or_forum_moderator, security_event from app.schemas import PostResponse -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict class PostCreate(BaseModel): thread_id: UUID - user_id: UUID content: str + model_config = ConfigDict(extra="forbid") -class PostUpdate(PostCreate): - pass +class PostUpdate(BaseModel): + content: str + model_config = ConfigDict(extra="forbid") router = APIRouter() @router.post('/posts', response_model=PostResponse) -def create_post(post: PostCreate, db: Session = Depends(get_db)): - new_post = Post(thread_id=post.thread_id, user_id=post.user_id, content=post.content) +def create_post( + post: PostCreate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + enforce_rate_limit(request, "forum_mutation", actor_id=current_user.id) + if not db.query(Thread).filter_by(id=post.thread_id).first(): + raise HTTPException(status_code=404, detail='Thread not found') + new_post = Post(thread_id=post.thread_id, user_id=current_user.id, content=post.content) db.add(new_post) db.commit() db.refresh(new_post) @@ -37,22 +49,49 @@ def get_post(post_id: UUID, db: Session = Depends(get_db)): return post @router.put('/posts/{post_id}', response_model=PostResponse) -def update_post(post_id: UUID, post: PostUpdate, db: Session = Depends(get_db)): +def update_post( + post_id: UUID, + post: PostUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + enforce_rate_limit(request, "forum_mutation", actor_id=current_user.id) db_post = db.query(Post).filter_by(id=post_id).first() if not db_post: raise HTTPException(status_code=404, detail='Post not found') - db_post.thread_id = post.thread_id - db_post.user_id = post.user_id + require_owner_or_forum_moderator( + actor_id=current_user.id, actor_role=current_user.role, owner_id=db_post.user_id + ) db_post.content = post.content db.commit() db.refresh(db_post) return db_post @router.delete('/posts/{post_id}') -def delete_post(post_id: UUID, db: Session = Depends(get_db)): +def delete_post( + post_id: UUID, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + enforce_rate_limit(request, "forum_mutation", actor_id=current_user.id) db_post = db.query(Post).filter_by(id=post_id).first() if not db_post: raise HTTPException(status_code=404, detail='Post not found') + require_owner_or_forum_moderator( + actor_id=current_user.id, actor_role=current_user.role, owner_id=db_post.user_id + ) + if current_user.id != db_post.user_id: + security_event( + action="forum_post_delete", + result="allowed", + actor_id=current_user.id, + target_type="post", + target_id=db_post.id, + reason="moderator_override", + request_id=getattr(request.state, "request_id", None), + ) db.delete(db_post) db.commit() return {'message': 'Post deleted'} diff --git a/backend/app/api/routes/progress.py b/backend/app/api/routes/progress.py index 391169b..437ea07 100644 --- a/backend/app/api/routes/progress.py +++ b/backend/app/api/routes/progress.py @@ -3,24 +3,65 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ from app.crud import progress as crud from app.database import get_db from app.deps import get_current_user, require_roles from app.enum import ProgressStatus -from app.models import SegmentProgress, StudentCourse, StudentUnitProgress, User +from app.models import Enrollment, Section, SegmentProgress, StudentCourse, StudentUnitProgress, User from app.schemas import CompleteSegmentRequest, SegmentResponse router = APIRouter() +def _can_access_student_course(db: Session, actor: User, student_course: StudentCourse) -> bool: + if actor.role in {"admin", "super_admin"}: + return True + if actor.role == "student": + return student_course.student_id == actor.id + if actor.role in {"teacher", "instructor"} and student_course.section_id: + return ( + db.query(Section) + .filter(Section.id == student_course.section_id, Section.created_by == actor.id) + .first() + is not None + or db.query(Enrollment) + .filter( + Enrollment.section_id == student_course.section_id, + Enrollment.user_id == actor.id, + Enrollment.role_in_section.in_(["teacher", "instructor"]), + Enrollment.status == "active", + ) + .first() + is not None + ) + return False + + +def _require_student_course(db: Session, actor: User, student_course_id: UUID) -> StudentCourse: + student_course = db.get(StudentCourse, student_course_id) + if student_course is None or not _can_access_student_course(db, actor, student_course): + raise HTTPException(status_code=404, detail="Progress record not found.") + return student_course + + +def _require_unit_progress(db: Session, actor: User, progress_id: UUID) -> StudentUnitProgress: + progress = db.get(StudentUnitProgress, progress_id) + if progress is None: + raise HTTPException(status_code=404, detail="Progress record not found.") + _require_student_course(db, actor, progress.student_course_id) + return progress + + @router.post("/unit") def create_unit_progress( student_course_id: UUID, unit_id: UUID, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor")), ): + _require_student_course(db, current_user, student_course_id) return crud.create_student_unit_progress(db, student_course_id, unit_id) @@ -29,8 +70,9 @@ def update_unit_progress( progress_id: UUID, status: str, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor")), ): + _require_unit_progress(db, current_user, progress_id) return crud.update_student_unit_progress_status(db, progress_id, status) @@ -39,8 +81,9 @@ def get_unit_progress( student_course_id: UUID, unit_id: UUID, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher", "student")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "student")), ): + _require_student_course(db, current_user, student_course_id) return crud.get_student_unit_progress(db, student_course_id, unit_id) @@ -48,11 +91,9 @@ def get_unit_progress( def get_segment( student_unit_id: UUID, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher", "student")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "student")), ): - unit_progress = db.get(StudentUnitProgress, student_unit_id) - if not unit_progress: - raise HTTPException(status_code=404, detail="Unit progress not found.") + unit_progress = _require_unit_progress(db, current_user, student_unit_id) current_segment = crud.resolve_governed_segment_for_unit_progress( db, student_unit_id @@ -75,7 +116,7 @@ def get_segment( def complete_segment( request: CompleteSegmentRequest, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher", "student")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "student")), ): segment = ( db.query(SegmentProgress) @@ -88,6 +129,7 @@ def complete_segment( if not segment: raise HTTPException(status_code=404, detail="Segment not found.") + _require_unit_progress(db, current_user, segment.student_unit_id) updated = crud.update_segment_progress_status(db, segment.id, ProgressStatus.COMPLETED) unit_progress = db.get(StudentUnitProgress, segment.student_unit_id) @@ -106,10 +148,32 @@ def get_streak( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - if user_id and current_user.role not in {"admin", "teacher"}: + if user_id and current_user.role not in {"admin", "super_admin", "teacher", "instructor"}: raise HTTPException(status_code=403, detail="Not authorized") target_user_id = user_id or current_user.id + if target_user_id != current_user.id and current_user.role in {"teacher", "instructor"}: + visible_learner = ( + db.query(StudentCourse) + .join(Section, Section.id == StudentCourse.section_id) + .filter( + StudentCourse.student_id == target_user_id, + or_( + Section.created_by == current_user.id, + Section.enrollments.any( + and_( + Enrollment.user_id == current_user.id, + Enrollment.role_in_section.in_(["teacher", "instructor"]), + Enrollment.status == "active", + ) + ), + ), + ) + .first() + ) + if visible_learner is None: + raise HTTPException(status_code=404, detail="Progress record not found.") + completed_segments = ( db.query(SegmentProgress) .join(StudentUnitProgress, StudentUnitProgress.id == SegmentProgress.student_unit_id) diff --git a/backend/app/api/routes/sections.py b/backend/app/api/routes/sections.py index e0ea910..640df42 100644 --- a/backend/app/api/routes/sections.py +++ b/backend/app/api/routes/sections.py @@ -3,7 +3,7 @@ from app.database import get_db from app.deps import get_current_user, require_org_roles -from app.models import Section, Enrollment, OrganizationMembership, User +from app.models import Course, CourseVersion, Section, Enrollment, OrganizationMembership, User from app.enum import SectionMode from app.schemas import SectionCreateRequest, SectionResponse, EnrollmentCreateRequest, EnrollmentResponse from app.section_scope import require_scoped_section @@ -18,6 +18,18 @@ def create_section( current_user=Depends(get_current_user), membership=Depends(require_org_roles("teacher", "org_admin", "instructor")), ): + course_version = ( + db.query(CourseVersion) + .join(Course, Course.id == CourseVersion.course_id) + .filter( + CourseVersion.id == payload.course_version_id, + (Course.organization_id.is_(None)) + | (Course.organization_id == membership.organization_id), + ) + .first() + ) + if course_version is None: + raise HTTPException(status_code=404, detail="Course version not found") section = Section( organization_id=membership.organization_id, course_version_id=payload.course_version_id, diff --git a/backend/app/api/routes/threads.py b/backend/app/api/routes/threads.py index b58af2b..9b68cf9 100644 --- a/backend/app/api/routes/threads.py +++ b/backend/app/api/routes/threads.py @@ -1,24 +1,34 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.orm import Session from uuid import UUID from app.database import get_db -from app.models import Thread +from app.deps import get_current_user +from app.models import Thread, User +from app.rate_limit import enforce_rate_limit +from app.security import require_owner_or_forum_moderator, security_event from app.schemas import ThreadResponse -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict class ThreadCreate(BaseModel): - user_id: UUID title: str + model_config = ConfigDict(extra="forbid") -class ThreadUpdate(ThreadCreate): - pass +class ThreadUpdate(BaseModel): + title: str + model_config = ConfigDict(extra="forbid") router = APIRouter() @router.post('/threads', response_model=ThreadResponse) -def create_thread(thread: ThreadCreate, db: Session = Depends(get_db)): - new_thread = Thread(user_id=thread.user_id, title=thread.title) +def create_thread( + thread: ThreadCreate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + enforce_rate_limit(request, "forum_mutation", actor_id=current_user.id) + new_thread = Thread(user_id=current_user.id, title=thread.title) db.add(new_thread) db.commit() db.refresh(new_thread) @@ -36,21 +46,49 @@ def get_thread(thread_id: UUID, db: Session = Depends(get_db)): return thread @router.put('/threads/{thread_id}', response_model=ThreadResponse) -def update_thread(thread_id: UUID, thread: ThreadUpdate, db: Session = Depends(get_db)): +def update_thread( + thread_id: UUID, + thread: ThreadUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + enforce_rate_limit(request, "forum_mutation", actor_id=current_user.id) db_thread = db.query(Thread).filter_by(id=thread_id).first() if not db_thread: raise HTTPException(status_code=404, detail='Thread not found') - db_thread.user_id = thread.user_id + require_owner_or_forum_moderator( + actor_id=current_user.id, actor_role=current_user.role, owner_id=db_thread.user_id + ) db_thread.title = thread.title db.commit() db.refresh(db_thread) return db_thread @router.delete('/threads/{thread_id}') -def delete_thread(thread_id: UUID, db: Session = Depends(get_db)): +def delete_thread( + thread_id: UUID, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + enforce_rate_limit(request, "forum_mutation", actor_id=current_user.id) db_thread = db.query(Thread).filter_by(id=thread_id).first() if not db_thread: raise HTTPException(status_code=404, detail='Thread not found') + require_owner_or_forum_moderator( + actor_id=current_user.id, actor_role=current_user.role, owner_id=db_thread.user_id + ) + if current_user.id != db_thread.user_id: + security_event( + action="forum_thread_delete", + result="allowed", + actor_id=current_user.id, + target_type="thread", + target_id=db_thread.id, + reason="moderator_override", + request_id=getattr(request.state, "request_id", None), + ) db.delete(db_thread) db.commit() return {'message': 'Thread deleted'} diff --git a/backend/app/api/routes/units.py b/backend/app/api/routes/units.py index 4667c8a..fb82de3 100644 --- a/backend/app/api/routes/units.py +++ b/backend/app/api/routes/units.py @@ -5,6 +5,7 @@ from app.database import get_db from app.deps import require_roles from app.models import Unit, CourseVersion +from app.content_scope import course_for_unit, require_course_edit from app.schemas import UnitResponse from pydantic import BaseModel @@ -29,8 +30,13 @@ class VersionUnitCreate(BaseModel): def create_unit( unit: UnitCreate, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin")), ): + from app.models import Course + course = db.get(Course, unit.course_id) + if course is None: + raise HTTPException(status_code=404, detail='Course not found') + require_course_edit(db, current_user, course) new_unit = Unit( course_id=unit.course_id, title=unit.title, @@ -48,11 +54,13 @@ def create_unit_for_version( version_id: UUID, unit: VersionUnitCreate, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin")), ): version = db.query(CourseVersion).filter_by(id=version_id).first() if not version: raise HTTPException(status_code=404, detail='Course version not found') + from app.models import Course + require_course_edit(db, current_user, db.get(Course, version.course_id)) new_unit = Unit( course_id=version.course_id, course_version_id=version.id, @@ -70,14 +78,14 @@ def create_unit_for_version( def list_units_for_version( version_id: UUID, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin")), ): return db.query(Unit).filter_by(course_version_id=version_id).all() @router.get('/units', response_model=list[UnitResponse]) def list_units( db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin")), ): return db.query(Unit).all() @@ -85,7 +93,7 @@ def list_units( def get_unit( unit_id: UUID, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher", "student")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin", "student")), ): unit = db.query(Unit).filter_by(id=unit_id).first() if not unit: @@ -97,11 +105,19 @@ def update_unit( unit_id: UUID, unit: UnitUpdate, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin")), ): db_unit = db.query(Unit).filter_by(id=unit_id).first() if not db_unit: raise HTTPException(status_code=404, detail='Unit not found') + current_course = course_for_unit(db, db_unit.id) + require_course_edit(db, current_user, current_course) + if unit.course_id != db_unit.course_id: + from app.models import Course + target_course = db.get(Course, unit.course_id) + if target_course is None: + raise HTTPException(status_code=404, detail='Course not found') + require_course_edit(db, current_user, target_course) db_unit.course_id = unit.course_id db_unit.title = unit.title db_unit.content = unit.content @@ -114,11 +130,12 @@ def update_unit( def delete_unit( unit_id: UUID, db: Session = Depends(get_db), - current_user=Depends(require_roles("admin", "teacher")), + current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin")), ): db_unit = db.query(Unit).filter_by(id=unit_id).first() if not db_unit: raise HTTPException(status_code=404, detail='Unit not found') + require_course_edit(db, current_user, course_for_unit(db, db_unit.id)) db.delete(db_unit) db.commit() return {'message': 'Unit deleted'} diff --git a/backend/app/api/routes/uploads.py b/backend/app/api/routes/uploads.py index a29dd07..ed50b5b 100644 --- a/backend/app/api/routes/uploads.py +++ b/backend/app/api/routes/uploads.py @@ -1,4 +1,6 @@ import os +import struct +import time import uuid from pathlib import Path @@ -6,6 +8,9 @@ from app.deps import require_roles from app.models import User +from app.rate_limit import enforce_rate_limit +from app.security import security_event +from app.observability import emit_event, metrics router = APIRouter() @@ -20,6 +25,73 @@ ".png": "image/png", ".webp": "image/webp", } +MAX_IMAGE_DIMENSION = 12_000 +MAX_IMAGE_PIXELS = 40_000_000 + + +def _validate_dimensions(width: int, height: int) -> None: + if ( + width <= 0 + or height <= 0 + or width > MAX_IMAGE_DIMENSION + or height > MAX_IMAGE_DIMENSION + or width * height > MAX_IMAGE_PIXELS + ): + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail="Image dimensions are invalid or exceed the supported limit.", + ) + + +def _jpeg_dimensions(data: bytes) -> tuple[int, int] | None: + if not data.startswith(b"\xff\xd8"): + return None + offset = 2 + sof_markers = {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF} + while offset + 4 <= len(data): + if data[offset] != 0xFF: + offset += 1 + continue + marker = data[offset + 1] + offset += 2 + if marker in {0xD8, 0xD9}: + continue + if offset + 2 > len(data): + return None + segment_length = int.from_bytes(data[offset : offset + 2], "big") + if segment_length < 2 or offset + segment_length > len(data): + return None + if marker in sof_markers and segment_length >= 7: + height = int.from_bytes(data[offset + 3 : offset + 5], "big") + width = int.from_bytes(data[offset + 5 : offset + 7], "big") + return width, height + offset += segment_length + return None + + +def _image_dimensions(extension: str, data: bytes) -> tuple[int, int] | None: + if extension == ".png": + if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n" or data[12:16] != b"IHDR": + return None + return struct.unpack(">II", data[16:24]) + if extension == ".gif": + if len(data) < 10 or data[:6] not in {b"GIF87a", b"GIF89a"}: + return None + return struct.unpack("= 30 and data[23:26] == b"\x9d\x01\x2a": + return int.from_bytes(data[26:28], "little") & 0x3FFF, int.from_bytes(data[28:30], "little") & 0x3FFF + if chunk == b"VP8L" and len(data) >= 25 and data[20] == 0x2F: + bits = int.from_bytes(data[21:25], "little") + return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1 + return None def _store_image_upload(file: UploadFile, destination: str) -> str: @@ -46,6 +118,14 @@ def _store_image_upload(file: UploadFile, destination: str) -> str: detail="Image upload exceeds the 5 MB limit.", ) buffer.write(chunk) + data = temporary_path.read_bytes() + dimensions = _image_dimensions(extension, data) + if dimensions is None: + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail="Upload content does not match a supported image format.", + ) + _validate_dimensions(*dimensions) temporary_path.replace(final_path) except Exception: temporary_path.unlink(missing_ok=True) @@ -54,13 +134,42 @@ def _store_image_upload(file: UploadFile, destination: str) -> str: return filename +def _store_authorized_upload( + file: UploadFile, destination: str, request: Request, current_user: User, category: str +) -> str: + started_at = time.perf_counter() + metrics.increment("echoed_uploads_total", category=category, result="attempt") + try: + stored = _store_image_upload(file, destination) + except HTTPException as exc: + metrics.increment("echoed_uploads_total", category=category, result="rejected") + security_event( + action="upload_rejection", + result="denied", + actor_id=current_user.id, + target_type="image_upload", + reason=f"http_{exc.status_code}", + request_id=getattr(request.state, "request_id", None), + ) + raise + except Exception: + metrics.increment("echoed_uploads_total", category=category, result="failure") + emit_event("upload.failed", level=40, component="upload", category=category, result="failure") + raise + metrics.increment("echoed_uploads_total", category=category, result="success") + metrics.observe("echoed_upload_duration_ms", (time.perf_counter() - started_at) * 1000, category=category) + emit_event("upload.succeeded", component="upload", actor_id=current_user.id, category=category, result="success") + return stored + + @router.post("/upload/coloring") def upload_coloring( request: Request, file: UploadFile = File(...), - current_user: User = Depends(require_roles("admin", "teacher")), + current_user: User = Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin")), ): - filename = _store_image_upload(file, COLORINGS_PATH) + enforce_rate_limit(request, "upload", actor_id=current_user.id) + filename = _store_authorized_upload(file, COLORINGS_PATH, request, current_user, "coloring") file_url = request.url_for("colorings", path=filename) return {"file_path": str(file_url)} @@ -69,9 +178,10 @@ def upload_coloring( def upload_storybook_page( request: Request, file: UploadFile = File(...), - current_user: User = Depends(require_roles("admin", "teacher")), + current_user: User = Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin")), ): - filename = _store_image_upload(file, STORYBOOK_PATH) + enforce_rate_limit(request, "upload", actor_id=current_user.id) + filename = _store_authorized_upload(file, STORYBOOK_PATH, request, current_user, "storybook") file_url = request.url_for("storybook", path=filename) return {"file_path": str(file_url)} @@ -80,8 +190,9 @@ def upload_storybook_page( def upload_badge_image( request: Request, file: UploadFile = File(...), - current_user: User = Depends(require_roles("admin")), + current_user: User = Depends(require_roles("admin", "super_admin")), ): - filename = _store_image_upload(file, BADGES_PATH) + enforce_rate_limit(request, "upload", actor_id=current_user.id) + filename = _store_authorized_upload(file, BADGES_PATH, request, current_user, "badge") file_url = request.url_for("badges", path=filename) return {"file_path": str(file_url)} diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 7ad3d8a..a810d31 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -1,37 +1,82 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.orm import Session from app.database import get_db from app.deps import require_roles from app.models import User, Post, Thread, StudentBadge, user_units -from app.schemas import UserDto -from app.auth import hash_password +from app.rate_limit import enforce_rate_limit +from app.schemas import PlatformUserRoleUpdate, PlatformUserSummary, StudentUserSummary +from app.security import ( + HIGHEST_PLATFORM_ROLE, + PLATFORM_ADMIN_ROLES, + PLATFORM_ROLES, + can_manage_platform_target, + normalize_platform_role, + security_event, +) router = APIRouter() -@router.get("/users") +def _request_id(request: Request) -> str | None: + return getattr(request.state, "request_id", None) + + +def _reject_self_action(current_user: User, target: User, action: str) -> None: + if current_user.id == target.id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"You cannot {action} your own administrative account.", + ) + + +def _ensure_target_allowed(current_user: User, target: User, requested_role: str | None = None) -> None: + if not can_manage_platform_target(current_user.role, target.role, requested_role): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to modify this account.", + ) + + +def _ensure_super_admin_remains(db: Session, target: User, requested_role: str | None) -> None: + removes_highest_role = target.role == HIGHEST_PLATFORM_ROLE and requested_role != HIGHEST_PLATFORM_ROLE + if not removes_highest_role: + return + highest_admins = ( + db.query(User) + .filter(User.role == HIGHEST_PLATFORM_ROLE) + .with_for_update() + .all() + ) + if len(highest_admins) <= 1: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="This action would remove the final platform super administrator.", + ) + + +@router.get("/users", response_model=list[PlatformUserSummary]) def get_users( db: Session = Depends(get_db), - current_user: User = Depends(require_roles("admin")), + current_user: User = Depends(require_roles("admin", "super_admin")), ): return db.query(User).all() -@router.get("/users/students") +@router.get("/users/students", response_model=list[StudentUserSummary]) def get_student_users( db: Session = Depends(get_db), - current_user: User = Depends(require_roles("admin", "teacher")), + current_user: User = Depends(require_roles("admin", "super_admin", "teacher")), ): return db.query(User).filter(User.role == "student").all() -@router.get("/users/{user_id}") +@router.get("/users/{user_id}", response_model=PlatformUserSummary) def get_user_by_id( user_id: uuid.UUID, db: Session = Depends(get_db), - current_user: User = Depends(require_roles("admin")), + current_user: User = Depends(require_roles("admin", "super_admin")), ): user = db.query(User).filter(User.id == user_id).first() if not user: @@ -42,41 +87,81 @@ def get_user_by_id( @router.put("/users/{user_id}") def update_user( user_id: uuid.UUID, - user: UserDto, + user: PlatformUserRoleUpdate, + request: Request, db: Session = Depends(get_db), - current_user: User = Depends(require_roles("admin")), + current_user: User = Depends(require_roles("admin", "super_admin")), ): - db_user = db.query(User).filter(User.id == user_id).first() + enforce_rate_limit(request, "user_management", actor_id=current_user.id) + db_user = db.query(User).filter(User.id == user_id).with_for_update().first() if not db_user: raise HTTPException(status_code=404, detail="User not found") - - db_user.firstname = user.firstname - db_user.lastname = user.lastname - db_user.username = user.username - db_user.email = user.email - db_user.role = user.role.lower() - - if user.password: - db_user.hashed_password = hash_password(user.password) - + _reject_self_action(current_user, db_user, "change the role of") + try: + requested_role = normalize_platform_role(user.role) + except ValueError as exc: + raise HTTPException(status_code=422, detail="Unsupported platform role.") from exc + _ensure_target_allowed(current_user, db_user, requested_role) + try: + _ensure_super_admin_remains(db, db_user, requested_role) + except HTTPException: + security_event( + action="platform_role_change", + result="denied", + actor_id=current_user.id, + target_type="user", + target_id=db_user.id, + reason="final_super_admin", + request_id=_request_id(request), + ) + raise + previous_role = db_user.role + db_user.role = requested_role db.commit() - return {"message": "User updated successfully"} + security_event( + action="platform_role_change", + result="allowed", + actor_id=current_user.id, + target_type="user", + target_id=db_user.id, + reason=f"{previous_role}_to_{requested_role}", + request_id=_request_id(request), + ) + return {"message": "User role updated successfully"} @router.delete("/users/{user_id}") def delete_user( user_id: str, + request: Request, db: Session = Depends(get_db), - current_user: User = Depends(require_roles("admin")), + current_user: User = Depends(require_roles("admin", "super_admin")), ): + enforce_rate_limit(request, "user_management", actor_id=current_user.id) try: uid = uuid.UUID(user_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid user id") - db_user = db.query(User).filter(User.id == uid).first() + db_user = db.query(User).filter(User.id == uid).with_for_update().first() if not db_user: raise HTTPException(status_code=404, detail="User not found") + _reject_self_action(current_user, db_user, "delete") + _ensure_target_allowed(current_user, db_user) + try: + _ensure_super_admin_remains(db, db_user, None) + except HTTPException: + security_event( + action="platform_user_delete", + result="denied", + actor_id=current_user.id, + target_type="user", + target_id=db_user.id, + reason="final_super_admin", + request_id=_request_id(request), + ) + raise + db.query(Post).filter(Post.user_id == uid).delete() user_threads = db.query(Thread).filter(Thread.user_id == uid).all() @@ -89,4 +174,12 @@ def delete_user( db.delete(db_user) db.commit() + security_event( + action="platform_user_delete", + result="allowed", + actor_id=current_user.id, + target_type="user", + target_id=uid, + request_id=_request_id(request), + ) return {"message": "User deleted successfully"} diff --git a/backend/app/auth.py b/backend/app/auth.py index 2e93747..709403c 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from datetime import datetime, timedelta, timezone -import os from typing import Iterable from uuid import UUID @@ -8,17 +7,19 @@ from jose import JWTError, jwt from sqlalchemy.orm import Session from sqlalchemy import or_ -from fastapi import Depends, HTTPException, status +from sqlalchemy.exc import SQLAlchemyError +from fastapi import Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordBearer from app.enum import MembershipStatus, OrganizationType from app.models import Organization, OrganizationMembership, User from app.database import SessionLocal from app.log import logger +from app.observability import emit_event, metrics +from app.operational_config import load_operational_settings -SECRET_KEY = os.getenv("JWT_SECRET") -if not SECRET_KEY: - raise RuntimeError("JWT_SECRET environment variable not set") +operational_settings = load_operational_settings() +SECRET_KEY = operational_settings.jwt_secret ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 120 @@ -77,6 +78,11 @@ def get_db(): db = SessionLocal() try: yield db + except SQLAlchemyError: + db.rollback() + metrics.increment("echoed_database_operations_total", operation="auth_session", result="failure") + emit_event("database.operation_failed", level=40, component="database", operation="auth_session", result="failure") + raise finally: db.close() @@ -143,6 +149,7 @@ def authenticate_user(db: Session, username: str, password: str): # Dependency to get the current user from JWT token def get_current_user( + request: Request, token: str = Depends(oauth2_scheme), db: Session = Depends(get_db), ): @@ -178,6 +185,12 @@ def get_current_user( logger.warning("Authentication rejected: subject not found") raise HTTPException(status_code=401, detail="User not found") + request.state.actor_class = "authenticated" + request.state.actor_id = str(user.id) + request.state.actor_role = user.role + active_org_id = payload.get("active_org_id") + if active_org_id: + request.state.organization_id = "present" return user except JWTError as e: logger.warning("Authentication rejected: token decode failed") diff --git a/backend/app/content_scope.py b/backend/app/content_scope.py new file mode 100644 index 0000000..00477c3 --- /dev/null +++ b/backend/app/content_scope.py @@ -0,0 +1,51 @@ +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.course_authoring_permissions import ( + require_course_authoring_capability, + resolve_course_authoring_capabilities, +) +from app.models import Activity, Course, Lesson, Unit, User + + +def course_for_unit(db: Session, unit_id: UUID) -> Course: + course = db.query(Course).join(Unit, Unit.course_id == Course.id).filter(Unit.id == unit_id).first() + if course is None: + raise HTTPException(status_code=404, detail="Content not found") + return course + + +def course_for_lesson(db: Session, lesson_id: UUID) -> Course: + course = ( + db.query(Course) + .join(Unit, Unit.course_id == Course.id) + .join(Lesson, Lesson.unit_id == Unit.id) + .filter(Lesson.id == lesson_id) + .first() + ) + if course is None: + raise HTTPException(status_code=404, detail="Content not found") + return course + + +def course_for_activity(db: Session, activity_id: UUID) -> Course: + course = ( + db.query(Course) + .join(Unit, Unit.course_id == Course.id) + .join(Lesson, Lesson.unit_id == Unit.id) + .join(Activity, Activity.lesson_id == Lesson.id) + .filter(Activity.id == activity_id) + .first() + ) + if course is None: + raise HTTPException(status_code=404, detail="Content not found") + return course + + +def require_course_edit(db: Session, current_user: User, course: Course) -> None: + require_course_authoring_capability( + resolve_course_authoring_capabilities(db, current_user, course=course), + "edit", + ) diff --git a/backend/app/course_authoring.py b/backend/app/course_authoring.py new file mode 100644 index 0000000..1419ea0 --- /dev/null +++ b/backend/app/course_authoring.py @@ -0,0 +1,532 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.course_authoring_permissions import CourseAuthoringCapabilities +from app.enum import CourseVersionStatus +from app.models import Activity, Assessment, Course, CourseVersion, Lesson, Source, StorybookPage, Unit + + +class CourseAuthoringValidationError(Exception): + def __init__(self, issues: list[dict]): + super().__init__("Course authoring validation failed") + self.issues = issues + + +class CourseAuthoringConflictError(Exception): + def __init__(self, course: Course): + super().__init__("Course draft revision conflict") + self.course_id = course.id + self.current_revision = course.revision_number + self.updated_at = course.updated_at + + +def _value(payload, name: str, default=None): + return getattr(payload, name, default) + + +def _issue(entity_type: str, field: str, message: str, corrective_context: str, entity_id=None): + return { + "severity": "blocking", + "entity_type": entity_type, + "entity_id": entity_id, + "field": field, + "message": message, + "corrective_context": corrective_context, + } + + +def validate_authoring_graph(payload) -> None: + issues: list[dict] = [] + if not (_value(payload, "title", "") or "").strip(): + issues.append(_issue("course", "title", "Course title is required.", "Add a concise course title.")) + age_min = _value(payload, "age_band_min") + age_max = _value(payload, "age_band_max") + if age_min is not None and age_max is not None and age_min > age_max: + issues.append( + _issue( + "course", + "age_band_max", + "Maximum age cannot be lower than minimum age.", + "Increase the maximum age or lower the minimum age.", + ) + ) + for unit in _value(payload, "units", []) or []: + if not (_value(unit, "title", "") or "").strip(): + issues.append(_issue("unit", "title", "Unit title is required.", "Name the unit.", _value(unit, "id"))) + for lesson in _value(unit, "lessons", []) or []: + if not (_value(lesson, "title", "") or "").strip(): + issues.append( + _issue("lesson", "title", "Lesson title is required.", "Name the lesson.", _value(lesson, "id")) + ) + duration = _value(lesson, "duration_minutes") + if duration is not None and duration < 1: + issues.append( + _issue( + "lesson", + "duration_minutes", + "Lesson duration must be at least one minute.", + "Enter a positive duration.", + _value(lesson, "id"), + ) + ) + for activity in _value(lesson, "activities", []) or []: + if not (_value(activity, "title", "") or "").strip(): + issues.append( + _issue( + "activity", + "title", + "Activity title is required.", + "Name the activity.", + _value(activity, "id"), + ) + ) + if not (_value(activity, "type", "") or "").strip(): + issues.append( + _issue( + "activity", + "type", + "Activity type is required.", + "Choose a supported activity type.", + _value(activity, "id"), + ) + ) + if issues: + raise CourseAuthoringValidationError(issues) + + +def _owned(existing: dict[UUID, object], requested_id: UUID | None): + return existing.get(requested_id) if requested_id else None + + +def _sync_assessment_refs(db: Session, owner, requested_ids, scope: str, course_id: UUID) -> None: + requested = set(requested_ids or []) + current_rows = list(getattr(owner, "assessments", [])) + if scope == "course": + current_rows = [assessment for assessment in current_rows if assessment.unit_id is None and assessment.lesson_id is None] + current = {assessment.id: assessment for assessment in current_rows} + found = db.query(Assessment).filter(Assessment.id.in_(requested)).all() if requested else [] + if len(found) != len(requested): + raise CourseAuthoringValidationError([_issue(scope, "assessment_ids", "One or more assessment references do not exist.", "Remove unavailable assessment references.", owner.id)]) + for assessment in found: + if assessment.course_id not in {None, course_id}: + raise CourseAuthoringValidationError([_issue(scope, "assessment_ids", "An assessment belongs to another course.", "Choose an assessment from this course.", owner.id)]) + assessment.course_id = course_id + if scope == "course": + assessment.unit_id = None + assessment.lesson_id = None + if scope == "unit": + assessment.unit_id = owner.id + assessment.lesson_id = None + if scope == "lesson": assessment.lesson_id = owner.id + for assessment_id, assessment in current.items(): + if assessment_id not in requested: + if scope == "course": assessment.course_id = None + if scope == "unit": assessment.unit_id = None + if scope == "lesson": assessment.lesson_id = None + + +def _sync_pages(db: Session, activity: Activity, payloads) -> None: + existing = {page.id: page for page in activity.storybook_pages} + retained: set[UUID] = set() + for index, payload in enumerate(payloads or [], start=1): + page = _owned(existing, _value(payload, "id")) + if page is None: + page = StorybookPage(activity_id=activity.id) + db.add(page) + page.image_url = (_value(payload, "image_url", "") or "").strip() + page.order = index + db.flush() + retained.add(page.id) + for page_id, page in existing.items(): + if page_id not in retained: + db.delete(page) + + +def _sync_activities(db: Session, lesson: Lesson, payloads) -> None: + existing = {activity.id: activity for activity in lesson.activities} + retained: set[UUID] = set() + for index, payload in enumerate(payloads or [], start=1): + activity = _owned(existing, _value(payload, "id")) + if activity is None: + activity = Activity(lesson_id=lesson.id) + db.add(activity) + activity.type = (_value(payload, "type", "") or "").strip() + activity.title = (_value(payload, "title", "") or "").strip() + activity.content = _value(payload, "content", "") or "" + activity.media_id = _value(payload, "media_id") + activity.order = index + db.flush() + retained.add(activity.id) + _sync_pages(db, activity, _value(payload, "pages", [])) + for activity_id, activity in existing.items(): + if activity_id not in retained: + db.delete(activity) + + +def _sync_sources(db: Session, lesson: Lesson, payloads) -> None: + existing = {source.id: source for source in lesson.sources} + retained: set[UUID] = set() + for payload in payloads or []: + source = _owned(existing, _value(payload, "id")) + if source is None: + source = Source(lesson_id=lesson.id) + db.add(source) + source.citation = (_value(payload, "citation", "") or "").strip() + source.url = _value(payload, "url") + db.flush() + retained.add(source.id) + for source_id, source in existing.items(): + if source_id not in retained: + db.delete(source) + + +def _sync_lessons(db: Session, unit: Unit, payloads) -> None: + existing = {lesson.id: lesson for lesson in unit.lessons} + retained: set[UUID] = set() + fields = ( + "objective", + "learning_objectives", + "teacher_notes", + "hook", + "content", + "guided_practice", + "independent_practice", + "assessment", + "duration_minutes", + ) + for index, payload in enumerate(payloads or [], start=1): + lesson = _owned(existing, _value(payload, "id")) + if lesson is None: + lesson = Lesson(unit_id=unit.id, review_status="draft") + db.add(lesson) + lesson.title = (_value(payload, "title", "") or "").strip() + lesson.order = index + for field in fields: + setattr(lesson, field, _value(payload, field)) + lesson.key_concepts = list(_value(payload, "key_concepts", []) or []) + lesson.discussion_questions = list(_value(payload, "discussion_questions", []) or []) + lesson.skill_tags = list(_value(payload, "skill_tags", []) or []) + lesson.standards_metadata = dict(_value(payload, "standards_metadata", {}) or {}) + db.flush() + retained.add(lesson.id) + _sync_sources(db, lesson, _value(payload, "sources", [])) + _sync_activities(db, lesson, _value(payload, "activities", [])) + _sync_assessment_refs(db, lesson, _value(payload, "assessment_ids", []), "lesson", unit.course_id) + for lesson_id, lesson in existing.items(): + if lesson_id not in retained: + db.delete(lesson) + + +def _sync_units(db: Session, course: Course, version: CourseVersion, payloads) -> None: + existing = {unit.id: unit for unit in course.units if unit.course_version_id == version.id} + retained: set[UUID] = set() + for index, payload in enumerate(payloads or [], start=1): + unit = _owned(existing, _value(payload, "id")) + if unit is None: + unit = Unit(course_id=course.id) + db.add(unit) + unit.course_version_id = version.id + unit.title = (_value(payload, "title", "") or "").strip() + unit.content = _value(payload, "content") + unit.order = index + db.flush() + retained.add(unit.id) + _sync_lessons(db, unit, _value(payload, "lessons", [])) + _sync_assessment_refs(db, unit, _value(payload, "assessment_ids", []), "unit", course.id) + for unit_id, unit in existing.items(): + if unit_id not in retained: + db.delete(unit) + + +def _current_draft_version(db: Session, course: Course) -> CourseVersion: + version = ( + db.query(CourseVersion) + .filter( + CourseVersion.course_id == course.id, + CourseVersion.status == CourseVersionStatus.DRAFT, + ) + .order_by(CourseVersion.version_number.desc()) + .first() + ) + if version is None: + latest = ( + db.query(CourseVersion) + .filter(CourseVersion.course_id == course.id) + .order_by(CourseVersion.version_number.desc()) + .first() + ) + version = CourseVersion( + course_id=course.id, + version_number=1 if latest is None else latest.version_number + 1, + status=CourseVersionStatus.DRAFT, + changelog="Authoring draft", + ) + db.add(version) + db.flush() + return version + + +def _draft_or_latest_version(db: Session, course: Course) -> CourseVersion: + version = ( + db.query(CourseVersion) + .filter(CourseVersion.course_id == course.id, CourseVersion.status == CourseVersionStatus.DRAFT) + .order_by(CourseVersion.version_number.desc()) + .first() + ) + if version is not None: + return version + version = ( + db.query(CourseVersion) + .filter(CourseVersion.course_id == course.id) + .order_by(CourseVersion.version_number.desc()) + .first() + ) + if version is None: + return _current_draft_version(db, course) + return version + + +def _apply_course_fields(course: Course, payload) -> None: + course.title = (_value(payload, "title", "") or "").strip() + course.description = _value(payload, "description", "") or "" + course.subject = _value(payload, "subject") + course.age_band_min = _value(payload, "age_band_min") + course.age_band_max = _value(payload, "age_band_max") + course.default_locale = _value(payload, "default_locale", "en") or "en" + course.learning_objectives = _value(payload, "learning_objectives") + course.skill_tags = list(_value(payload, "skill_tags", []) or []) + course.standards_metadata = dict(_value(payload, "standards_metadata", {}) or {}) + + +def find_idempotent_course( + db: Session, + *, + created_by: UUID, + organization_id: UUID | None, + idempotency_key: str, +) -> Course | None: + candidates = db.query(Course).filter(Course.created_by == created_by) + candidates = candidates.filter(Course.organization_id == organization_id) + for course in candidates.all(): + if (course.revision_metadata or {}).get("idempotency_key") == idempotency_key: + return course + return None + + +def create_course_draft( + db: Session, + payload, + *, + current_user_id: UUID, + organization_id: UUID | None, + idempotency_key: str, +) -> Course: + validate_authoring_graph(payload) + existing = find_idempotent_course( + db, + created_by=current_user_id, + organization_id=organization_id, + idempotency_key=idempotency_key, + ) + if existing is not None: + return existing + metadata = {"idempotency_key": idempotency_key} + metadata["authoring_state"] = "draft" + template_id = _value(payload, "template_id") + if template_id: + metadata["template_id"] = template_id + course = Course( + title="", + description="", + created_by=current_user_id, + organization_id=organization_id, + revision_number=1, + revision_status="draft", + revision_metadata=metadata, + updated_at=datetime.utcnow(), + ) + _apply_course_fields(course, payload) + db.add(course) + db.flush() + version = _current_draft_version(db, course) + _sync_units(db, course, version, _value(payload, "units", [])) + _sync_assessment_refs(db, course, _value(payload, "assessment_ids", []), "course", course.id) + return course + + +def update_course_draft(db: Session, course: Course, payload) -> Course: + validate_authoring_graph(payload) + expected_revision = _value(payload, "revision_number") + if expected_revision is not None and expected_revision != course.revision_number: + raise CourseAuthoringConflictError(course) + _apply_course_fields(course, payload) + metadata = dict(course.revision_metadata or {}) + if metadata.get("authoring_state") in {"approved", "published"}: + metadata["authoring_state"] = "draft" + metadata.pop("review_feedback", None) + course.revision_metadata = metadata + course.revision_number += 1 + course.updated_at = datetime.utcnow() + version = _current_draft_version(db, course) + _sync_units(db, course, version, _value(payload, "units", [])) + _sync_assessment_refs(db, course, _value(payload, "assessment_ids", []), "course", course.id) + db.flush() + return course + + +def serialize_course_draft( + db: Session, + course: Course, + capabilities: CourseAuthoringCapabilities, +) -> dict: + version = _draft_or_latest_version(db, course) + units = [] + version_units = [unit for unit in course.units if unit.course_version_id == version.id] + for unit in sorted(version_units, key=lambda item: (item.order or 0, str(item.id))): + lessons = [] + for lesson in sorted(unit.lessons, key=lambda item: (item.order or 0, str(item.id))): + activities = [] + for activity in sorted(lesson.activities, key=lambda item: (item.order or 0, str(item.id))): + activities.append( + { + "id": activity.id, + "type": activity.type, + "title": activity.title, + "content": activity.content or "", + "order": activity.order, + "media_id": activity.media_id, + "pages": [ + {"id": page.id, "image_url": page.image_url, "order": page.order} + for page in sorted( + activity.storybook_pages, + key=lambda item: (item.order or 0, str(item.id)), + ) + ], + } + ) + lessons.append( + { + "id": lesson.id, + "title": lesson.title, + "objective": lesson.objective, + "learning_objectives": lesson.learning_objectives, + "key_concepts": lesson.key_concepts or [], + "teacher_notes": lesson.teacher_notes, + "discussion_questions": lesson.discussion_questions or [], + "hook": lesson.hook, + "content": lesson.content, + "guided_practice": lesson.guided_practice, + "independent_practice": lesson.independent_practice, + "assessment": lesson.assessment, + "review_status": lesson.review_status, + "skill_tags": lesson.skill_tags or [], + "standards_metadata": lesson.standards_metadata or {}, + "order": lesson.order, + "duration_minutes": lesson.duration_minutes, + "sources": [ + {"id": source.id, "citation": source.citation, "url": source.url} + for source in lesson.sources + ], + "activities": activities, + "assessment_ids": [assessment.id for assessment in lesson.assessments], + } + ) + units.append( + { + "id": unit.id, + "title": unit.title, + "content": unit.content, + "order": unit.order, + "lessons": lessons, + "assessment_ids": [assessment.id for assessment in unit.assessments], + } + ) + return { + "id": course.id, + "title": course.title, + "description": course.description, + "subject": course.subject, + "age_band_min": course.age_band_min, + "age_band_max": course.age_band_max, + "default_locale": course.default_locale, + "learning_objectives": course.learning_objectives, + "skill_tags": course.skill_tags or [], + "standards_metadata": course.standards_metadata or {}, + "organization_id": course.organization_id, + "created_by": course.created_by, + "revision_number": course.revision_number, + "revision_status": (course.revision_metadata or {}).get("authoring_state", course.revision_status), + "revision_metadata": course.revision_metadata or {}, + "updated_at": course.updated_at or course.created_at or datetime.utcnow(), + "current_version_id": version.id, + "units": units, + "assessment_ids": [assessment.id for assessment in course.assessments], + "capabilities": capabilities.model_payload(), + } + + +def build_course_duplicate_payload(course: Course) -> dict: + units: list[dict] = [] + draft_versions = [version for version in course.versions if version.status == CourseVersionStatus.DRAFT] + selected_version = max(draft_versions or list(course.versions), key=lambda item: item.version_number, default=None) + selected_units = [unit for unit in course.units if selected_version is None or unit.course_version_id == selected_version.id] + for unit in sorted(selected_units, key=lambda item: (item.order or 0, str(item.id))): + lessons: list[dict] = [] + for lesson in sorted(unit.lessons, key=lambda item: (item.order or 0, str(item.id))): + activities: list[dict] = [] + for activity in sorted(lesson.activities, key=lambda item: (item.order or 0, str(item.id))): + activities.append( + { + "type": activity.type, + "title": activity.title, + "content": activity.content or "", + "media_id": activity.media_id, + "pages": [ + {"image_url": page.image_url} + for page in sorted( + activity.storybook_pages, + key=lambda item: (item.order or 0, str(item.id)), + ) + ], + } + ) + lessons.append( + { + "title": lesson.title, + "objective": lesson.objective, + "learning_objectives": lesson.learning_objectives, + "key_concepts": lesson.key_concepts or [], + "teacher_notes": lesson.teacher_notes, + "discussion_questions": lesson.discussion_questions or [], + "hook": lesson.hook, + "content": lesson.content, + "guided_practice": lesson.guided_practice, + "independent_practice": lesson.independent_practice, + "assessment": lesson.assessment, + "skill_tags": lesson.skill_tags or [], + "standards_metadata": lesson.standards_metadata or {}, + "duration_minutes": lesson.duration_minutes, + "sources": [ + {"citation": source.citation, "url": source.url} + for source in lesson.sources + ], + "activities": activities, + } + ) + units.append({"title": unit.title, "content": unit.content, "lessons": lessons}) + return { + "title": f"Copy of {course.title}", + "description": course.description, + "subject": course.subject, + "age_band_min": course.age_band_min, + "age_band_max": course.age_band_max, + "default_locale": course.default_locale, + "learning_objectives": course.learning_objectives, + "skill_tags": course.skill_tags or [], + "standards_metadata": course.standards_metadata or {}, + "units": units, + } diff --git a/backend/app/course_authoring_permissions.py b/backend/app/course_authoring_permissions.py new file mode 100644 index 0000000..d9093ac --- /dev/null +++ b/backend/app/course_authoring_permissions.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.enum import MembershipStatus +from app.models import Course, OrganizationMembership, User + + +@dataclass(frozen=True) +class CourseAuthoringCapabilities: + can_create: bool = False + can_view_draft: bool = False + can_edit: bool = False + can_duplicate: bool = False + can_preview: bool = False + can_submit_review: bool = False + can_review: bool = False + can_publish: bool = False + + def model_payload(self) -> dict[str, bool]: + return asdict(self) + + +def _enum_value(value) -> str | None: + return getattr(value, "value", value) + + +def _active_membership( + db: Session, + user_id: UUID, + organization_id: UUID | None, +) -> OrganizationMembership | None: + if organization_id is None: + return None + membership = ( + db.query(OrganizationMembership) + .filter( + OrganizationMembership.organization_id == organization_id, + OrganizationMembership.user_id == user_id, + OrganizationMembership.status == MembershipStatus.ACTIVE, + ) + .first() + ) + return membership + + +def resolve_course_authoring_capabilities( + db: Session, + current_user: User, + *, + active_organization_id: UUID | None = None, + course: Course | None = None, +) -> CourseAuthoringCapabilities: + """Resolve bounded authoring actions; every mutation must re-check these. + + Platform administrators retain broad authority for migration compatibility. + Organization roles are evaluated against the course organization for item + actions and against the active organization for collection actions. + """ + + platform_role = _enum_value(current_user.role) + if platform_role in {"admin", "super_admin"}: + return CourseAuthoringCapabilities( + can_create=True, + can_view_draft=True, + can_edit=True, + can_duplicate=True, + can_preview=True, + can_submit_review=True, + can_review=True, + can_publish=True, + ) + + organization_id = course.organization_id if course is not None else active_organization_id + authoring_state = (course.revision_metadata or {}).get("authoring_state", "draft") if course else "draft" + author_editable = authoring_state in {"draft", "changes_requested", "published"} + membership = _active_membership(db, current_user.id, organization_id) + membership_role = _enum_value(membership.role) if membership else None + + if membership_role in {"org_admin", "super_admin"}: + independent_reviewer = course is None or course.created_by != current_user.id + return CourseAuthoringCapabilities( + can_create=True, + can_view_draft=True, + can_edit=author_editable, + can_duplicate=True, + can_preview=True, + can_submit_review=author_editable, + can_review=independent_reviewer, + can_publish=independent_reviewer, + ) + + if membership_role == "content_admin": + return CourseAuthoringCapabilities( + can_create=True, + can_view_draft=True, + can_edit=author_editable, + can_duplicate=True, + can_preview=True, + can_submit_review=author_editable, + ) + + if membership_role in {"teacher", "instructor"}: + owns_draft = course is not None and course.created_by == current_user.id + is_editable = owns_draft and author_editable + return CourseAuthoringCapabilities( + can_create=False, + can_view_draft=is_editable, + can_edit=is_editable, + can_duplicate=True, + can_preview=is_editable, + can_submit_review=is_editable, + ) + + # Preserve the legacy unscoped teacher authoring contract during migration. + if platform_role in {"teacher", "instructor"} and ( + active_organization_id is None and (course is None or course.organization_id is None) + ): + owns_or_legacy = course is None or course.created_by in {None, current_user.id} + return CourseAuthoringCapabilities( + can_create=course is None, + can_view_draft=owns_or_legacy, + can_edit=owns_or_legacy, + can_duplicate=True, + can_preview=owns_or_legacy, + can_submit_review=owns_or_legacy, + ) + + return CourseAuthoringCapabilities() + + +def require_course_authoring_capability( + capabilities: CourseAuthoringCapabilities, + action: str, +) -> None: + attribute = f"can_{action}" + if not hasattr(capabilities, attribute) or not getattr(capabilities, attribute): + from fastapi import HTTPException, status + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to perform this course-authoring action.", + ) diff --git a/backend/app/course_exchange.py b/backend/app/course_exchange.py new file mode 100644 index 0000000..a23b387 --- /dev/null +++ b/backend/app/course_exchange.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from app.schemas import CourseAuthoringDraftRequest + + +@dataclass(frozen=True) +class ExchangeValidationIssue: + severity: str + path: str + code: str + message: str + + +class CourseExchangeAdapter(Protocol): + format_id: str + + def validate_import(self, payload: dict) -> list[ExchangeValidationIssue]: ... + def to_authoring_draft(self, payload: dict) -> CourseAuthoringDraftRequest: ... + def export(self, payload: dict) -> dict: ... + + +class EchoedJsonAdapter: + """Lossless first-release exchange format; cartridge/QTI adapters plug in here later.""" + + format_id = "echoed-json-v1" + + def validate_import(self, payload: dict) -> list[ExchangeValidationIssue]: + issues: list[ExchangeValidationIssue] = [] + if payload.get("format") != self.format_id: + issues.append(ExchangeValidationIssue("blocking", "format", "unsupported_format", "Only echoed-json-v1 is supported in this release.")) + course = payload.get("course") + if not isinstance(course, dict): + issues.append(ExchangeValidationIssue("blocking", "course", "missing_course", "The exchange document must contain a course object.")) + return issues + supported = set(CourseAuthoringDraftRequest.model_fields) + for field in sorted(set(course) - supported - {"id", "organization_id", "created_by", "revision_status", "revision_metadata", "updated_at", "current_version_id", "assessment_ids", "capabilities"}): + issues.append(ExchangeValidationIssue("warning", f"course.{field}", "unsupported_construct", f"The field '{field}' will not be imported.")) + try: + CourseAuthoringDraftRequest.model_validate({key: value for key, value in course.items() if key in supported}) + except Exception as exc: + issues.append(ExchangeValidationIssue("blocking", "course", "invalid_course_graph", str(exc))) + return issues + + def to_authoring_draft(self, payload: dict) -> CourseAuthoringDraftRequest: + supported = set(CourseAuthoringDraftRequest.model_fields) + return CourseAuthoringDraftRequest.model_validate({key: value for key, value in payload["course"].items() if key in supported}) + + def export(self, payload: dict) -> dict: + return {"format": self.format_id, "course": payload} + + +echoed_json_adapter = EchoedJsonAdapter() diff --git a/backend/app/course_templates.py b/backend/app/course_templates.py new file mode 100644 index 0000000..bb3502c --- /dev/null +++ b/backend/app/course_templates.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +TEMPLATES = { + "backward-design": { + "id": "backward-design", + "name": "Backward design course", + "description": "Plan outcomes, evidence, then learning experiences.", + "course": { + "title": "", + "description": "", + "learning_objectives": "", + "units": [{"title": "Unit 1", "content": "", "lessons": [{"title": "Lesson 1", "objective": "", "activities": []}]}], + }, + }, + "workshop-series": { + "id": "workshop-series", + "name": "Workshop series", + "description": "A repeatable sequence for facilitated learning.", + "course": { + "title": "", + "description": "", + "units": [{"title": "Workshop 1", "lessons": [{"title": "Explore, practice, reflect", "activities": [{"type": "discussion", "title": "Opening discussion", "content": ""}]}]}], + }, + }, +} + + +def template_catalog() -> list[dict]: + return list(TEMPLATES.values()) + + +def template_course(template_id: str) -> dict | None: + template = TEMPLATES.get(template_id) + return template["course"] if template else None diff --git a/backend/app/database.py b/backend/app/database.py index 4cfeb09..7e28ed7 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -1,12 +1,10 @@ from sqlalchemy import create_engine +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import sessionmaker -import os -from dotenv import load_dotenv +from app.operational_config import load_operational_settings -# Load environment variables -load_dotenv() - -DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://echoed_user:your_secure_password@localhost/echoed") +operational_settings = load_operational_settings() +DATABASE_URL = operational_settings.database_url connect_args = {} if DATABASE_URL.startswith("sqlite"): @@ -26,11 +24,17 @@ def _fk_pragma_on_connect(dbapi_con, con_record): SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) from app.models import Base +from app.observability import emit_event, metrics # Dependency to get DB session def get_db(): db = SessionLocal() try: yield db + except SQLAlchemyError: + db.rollback() + metrics.increment("echoed_database_operations_total", operation="session", result="failure") + emit_event("database.operation_failed", level=40, component="database", operation="session", result="failure") + raise finally: - db.close() \ No newline at end of file + db.close() diff --git a/backend/app/deps.py b/backend/app/deps.py index eb6c47a..f96c18a 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -1,17 +1,25 @@ -from fastapi import Depends, HTTPException, status, Header +from fastapi import Depends, Header, HTTPException, status from sqlalchemy.orm import Session +from sqlalchemy.exc import SQLAlchemyError import uuid from app.auth import get_current_user as auth_get_current_user from app.database import SessionLocal from app.enum import MembershipStatus from app.models import User, OrganizationMembership +from app.security import ORGANIZATION_ROLES, PLATFORM_ROLES, validate_role_allowlist +from app.observability import emit_event, metrics def get_db(): db = SessionLocal() try: yield db + except SQLAlchemyError: + db.rollback() + metrics.increment("echoed_database_operations_total", operation="authorization_session", result="failure") + emit_event("database.operation_failed", level=40, component="database", operation="authorization_session", result="failure") + raise finally: db.close() @@ -20,8 +28,21 @@ def get_db(): def require_roles(*roles: str): + allowed_roles = validate_role_allowlist(roles, PLATFORM_ROLES, scope="platform") + def role_checker(current_user: User = Depends(get_current_user)) -> User: - if current_user.role not in roles: + if current_user.role not in allowed_roles: + metrics.increment("echoed_authorization_denials_total", scope="platform", reason="role") + emit_event( + "authorization.denied", + level=30, + component="authorization", + actor_id=current_user.id, + actor_role=current_user.role, + scope="platform", + reason="role_not_allowed", + result="denied", + ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You do not have permission to access this resource.", @@ -46,20 +67,15 @@ def get_active_org_id( def require_org_roles(*roles: str): + allowed_roles = validate_role_allowlist(roles, ORGANIZATION_ROLES, scope="organization") + def org_role_checker( active_org_id: str | None = Depends(get_active_org_id), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> OrganizationMembership: - if current_user.role == "super_admin": - membership = ( - db.query(OrganizationMembership) - .filter(OrganizationMembership.organization_id == active_org_id) - .first() - ) - if membership: - return membership if not active_org_id: + metrics.increment("echoed_authorization_denials_total", scope="organization", reason="missing_context") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Missing active organization.", @@ -73,7 +89,26 @@ def org_role_checker( ) .first() ) - if not membership or membership.role.value not in roles: + if current_user.role == "super_admin": + return membership or OrganizationMembership( + organization_id=active_org_id, + user_id=current_user.id, + role="super_admin", + status=MembershipStatus.ACTIVE, + ) + if not membership or membership.role.value not in allowed_roles: + reason = "inactive_or_cross_organization" if not membership else "role" + metrics.increment("echoed_authorization_denials_total", scope="organization", reason=reason) + emit_event( + "authorization.denied", + level=30, + component="authorization", + actor_id=current_user.id, + actor_role=current_user.role, + scope="organization", + reason=reason, + result="denied", + ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You do not have permission to access this resource.", diff --git a/backend/app/lesson_governance.py b/backend/app/lesson_governance.py index 69b0d69..b626031 100644 --- a/backend/app/lesson_governance.py +++ b/backend/app/lesson_governance.py @@ -1722,19 +1722,30 @@ def _serialize_media(media: Media | None) -> MediaResponse | None: ) -def serialize_course(course: Course, *, viewer_role: str) -> CourseResponse: +def serialize_course(course: Course, *, viewer_role: str, learner_preview: bool = False) -> CourseResponse: learner_view = viewer_role == "student" + published_versions = [version for version in course.versions or [] if getattr(version.status, "value", version.status) == "published"] + published_version = max(published_versions, key=lambda item: item.version_number, default=None) + draft_versions = [version for version in course.versions or [] if getattr(version.status, "value", version.status) == "draft"] + preview_version = max(draft_versions, key=lambda item: item.version_number, default=None) + delivery_units = ( + [unit for unit in course.units or [] if unit.course_version_id == preview_version.id] + if learner_preview and preview_version is not None + else ([unit for unit in course.units or [] if unit.course_version_id == published_version.id] + if learner_view and published_version is not None else list(course.units or [])) + ) + published_snapshot = (course.revision_metadata or {}).get("published_snapshot", {}) if learner_view and not learner_preview else {} return CourseResponse( id=course.id, - title=course.title, - description=course.description, - learning_objectives=course.learning_objectives, - skill_tags=course.skill_tags or [], - standards_metadata=course.standards_metadata or {}, - revision_number=course.revision_number, + title=published_snapshot.get("title", course.title), + description=published_snapshot.get("description", course.description), + learning_objectives=published_snapshot.get("learning_objectives", course.learning_objectives), + skill_tags=published_snapshot.get("skill_tags", course.skill_tags or []), + standards_metadata=published_snapshot.get("standards_metadata", course.standards_metadata or {}), + revision_number=published_snapshot.get("revision_number", course.revision_number), revision_label=course.revision_label, revision_status=course.revision_status, - revision_metadata=course.revision_metadata or {}, + revision_metadata={} if learner_view else course.revision_metadata or {}, previous_revision_id=course.previous_revision_id, superseded_by_id=course.superseded_by_id, lineage_status=course.lineage_status, @@ -1761,17 +1772,17 @@ def serialize_course(course: Course, *, viewer_role: str) -> CourseResponse: serialize_lesson(lesson, viewer_role=viewer_role) for lesson in ( governed_lessons_for_unit(unit).lessons - if learner_view + if learner_view and not learner_preview else _sort_lessons_for_delivery(unit.lessons or []) ) ], learner_availability=( - governed_lessons_for_unit(unit).state if learner_view else None + governed_lessons_for_unit(unit).state if learner_view and not learner_preview else None ), learner_availability_detail=( - governed_lessons_for_unit(unit).detail if learner_view else None + governed_lessons_for_unit(unit).detail if learner_view and not learner_preview else None ), ) - for unit in course.units or [] + for unit in delivery_units ], ) diff --git a/backend/app/log.py b/backend/app/log.py index 22beda3..f64fad0 100644 --- a/backend/app/log.py +++ b/backend/app/log.py @@ -1,13 +1,4 @@ -import logging -import os +from app.observability import emit_event, logger, metrics, record_outcome, redact -LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper() -level = getattr(logging, LOG_LEVEL, logging.INFO) - -logging.basicConfig( - level=level, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) - -logger = logging.getLogger('echoed') +__all__ = ["emit_event", "logger", "metrics", "record_outcome", "redact"] diff --git a/backend/app/main.py b/backend/app/main.py index 14231ed..f3c78e3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,13 +1,23 @@ +import asyncio +from contextlib import asynccontextmanager import os import re +import secrets import time import uuid -from fastapi import FastAPI, HTTPException, Request, status +from app.operational_config import load_operational_settings + +operational_settings = load_operational_settings() + +from fastapi import FastAPI, Header, HTTPException, Request, status from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError +from starlette.concurrency import run_in_threadpool +from starlette.middleware.trustedhost import TrustedHostMiddleware from app.api.routes import ( activities, @@ -37,13 +47,40 @@ v2_platform, ) from app.database import engine -from app.log import logger +from app.network_trust import resolve_network_context +from app.observability import ( + correlation_id_context, + emit_event, + metrics, + request_id_context, + settings, +) + +@asynccontextmanager +async def lifespan(application: FastAPI): + application.state.accepting_requests = True + emit_event( + "application.started", + component="lifecycle", + environment=operational_settings.environment, + release_version=operational_settings.release_version, + deployment_id=operational_settings.deployment_id, + result="success", + ) + try: + yield + finally: + application.state.accepting_requests = False + emit_event("application.shutdown.started", component="lifecycle", result="started") + await run_in_threadpool(engine.dispose) + emit_event("application.shutdown.completed", component="lifecycle", result="success") + -app = FastAPI() +app = FastAPI(lifespan=lifespan) -STORYBOOK_PATH = os.getenv("STORYBOOK_PATH", "./storybook") -COLORINGS_PATH = os.getenv("COLORINGS_PATH", "./colorings") -BADGES_PATH = os.getenv("BADGES_PATH", "./badges") +STORYBOOK_PATH = str(operational_settings.storybook_path) +COLORINGS_PATH = str(operational_settings.colorings_path) +BADGES_PATH = str(operational_settings.badges_path) os.makedirs(STORYBOOK_PATH, exist_ok=True) os.makedirs(COLORINGS_PATH, exist_ok=True) @@ -62,9 +99,9 @@ def _parse_allowed_origins(raw_origins: str) -> list[str]: ] -allowed_origins = _parse_allowed_origins( - os.getenv("FRONTEND_URL", "http://localhost:4200,http://127.0.0.1:4200") -) +allowed_origins = list(operational_settings.allowed_origins) + +app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(operational_settings.allowed_hosts)) app.add_middleware( CORSMiddleware, @@ -75,44 +112,138 @@ def _parse_allowed_origins(raw_origins: str) -> list[str]: ) REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") +CORRELATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{1,64}$") + + +def _safe_incoming(value: str, pattern: re.Pattern[str]) -> str | None: + return value if pattern.fullmatch(value) else None + + +def _route_template(request: Request) -> str: + route = request.scope.get("route") + path = getattr(route, "path", None) + if not isinstance(path, str): + return "unmatched" + root_path = request.scope.get("root_path", "") + if ( + isinstance(root_path, str) + and root_path not in {"", "/"} + and path != root_path + and not path.startswith(f"{root_path.rstrip('/')}/") + ): + return f"{root_path.rstrip('/')}/{path.lstrip('/')}" + rendered_path = path + for name, value in request.path_params.items(): + rendered_path = re.sub( + rf"{{{re.escape(name)}(?::[^}}]+)?}}", + str(value), + rendered_path, + ) + actual_path = request.scope.get("path", "") + if ( + isinstance(actual_path, str) + and rendered_path != path + and actual_path != rendered_path + and actual_path.endswith(rendered_path) + ): + prefix = actual_path[: -len(rendered_path)].rstrip("/") + if prefix: + return f"{prefix}/{path.lstrip('/')}" + return path @app.middleware("http") async def add_operational_context(request: Request, call_next): incoming_request_id = request.headers.get("X-Request-ID", "") - request_id = ( - incoming_request_id - if REQUEST_ID_PATTERN.fullmatch(incoming_request_id) - else str(uuid.uuid4()) - ) + request_id = _safe_incoming(incoming_request_id, REQUEST_ID_PATTERN) or str(uuid.uuid4()) + incoming_correlation_id = request.headers.get(settings.correlation_header, "") + correlation_id = _safe_incoming(incoming_correlation_id, CORRELATION_ID_PATTERN) started_at = time.perf_counter() request.state.request_id = request_id + request.state.correlation_id = correlation_id + request.state.actor_class = "anonymous" + network_context = resolve_network_context(request, operational_settings) + request.state.client_ip = network_context.client_ip + request.state.authoritative_scheme = network_context.scheme + request.state.authoritative_host = network_context.host + request.state.proxy_trusted = network_context.proxy_trusted + request_token = request_id_context.set(request_id) + correlation_token = correlation_id_context.set(correlation_id) + metrics.gauge_add("echoed_http_active_requests", 1) try: response = await call_next(request) - except Exception: - logger.exception( - "request_failed request_id=%s method=%s path=%s", - request_id, - request.method, - request.url.path, + except Exception as exc: + emit_event( + "request.unhandled_exception", + level=40, + component="http", + message="Unexpected request failure", + exc_info=exc, + method=request.method, + route=_route_template(request), + result="error", ) - raise + metrics.increment("echoed_request_failures_total", category="unhandled_exception") + response = JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Something went wrong.", "request_id": request_id}, + ) + finally: + metrics.gauge_add("echoed_http_active_requests", -1) - duration_ms = (time.perf_counter() - started_at) * 1000 - response.headers["X-Request-ID"] = request_id - response.headers["X-Content-Type-Options"] = "nosniff" - response.headers["X-Frame-Options"] = "DENY" - response.headers["Referrer-Policy"] = "no-referrer" - logger.info( - "request_complete request_id=%s method=%s path=%s status=%s duration_ms=%.2f", - request_id, - request.method, - request.url.path, - response.status_code, - duration_ms, - ) - return response + try: + duration_ms = (time.perf_counter() - started_at) * 1000 + route = _route_template(request) + status_family = f"{response.status_code // 100}xx" + metrics.increment("echoed_http_requests_total", method=request.method, route=route, status_family=status_family) + metrics.observe("echoed_http_request_duration_ms", duration_ms, method=request.method, route=route) + if response.status_code in {401, 403}: + category = "authentication" if response.status_code == 401 else "authorization" + metrics.increment("echoed_request_denials_total", category=category, route=route) + emit_event( + "authorization.denied" if response.status_code == 403 else "authentication.required", + component="http", + method=request.method, + route=route, + status=response.status_code, + actor_class=request.state.actor_class, + result="denied", + ) + elif response.status_code == 422: + metrics.increment("echoed_request_failures_total", category="validation") + emit_event("request.validation_failed", component="http", method=request.method, route=route, result="denied") + if settings.request_logging: + emit_event( + "request.completed", + component="http", + method=request.method, + route=route, + status=response.status_code, + duration_ms=round(duration_ms, 2), + actor_class=request.state.actor_class, + organization_context=bool(request.headers.get("X-Org-Id")), + result="success" if response.status_code < 400 else "failure", + ) + if duration_ms >= settings.slow_request_threshold_ms: + emit_event( + "request.slow", + level=30, + component="http", + method=request.method, + route=route, + duration_ms=round(duration_ms, 2), + ) + response.headers["X-Request-ID"] = request_id + if correlation_id: + response.headers[settings.correlation_header] = correlation_id + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "no-referrer" + return response + finally: + request_id_context.reset(request_token) + correlation_id_context.reset(correlation_token) app.include_router(progress.router, prefix="/api", tags=["Progress"]) app.include_router(progress.router, prefix="/api/progress", tags=["Progress"]) @@ -152,14 +283,37 @@ def liveness(): return {"status": "live"} +def _database_ready() -> None: + with engine.connect() as connection: + connection.execute(text("SELECT 1")) + + @app.get("/health/ready", include_in_schema=False) -def readiness(): +async def readiness(): + started_at = time.perf_counter() try: - with engine.connect() as connection: - connection.execute(text("SELECT 1")) - except SQLAlchemyError as exc: - raise HTTPException( + await asyncio.wait_for(run_in_threadpool(_database_ready), timeout=settings.readiness_timeout_seconds) + except (SQLAlchemyError, TimeoutError): + duration_ms = (time.perf_counter() - started_at) * 1000 + metrics.increment("echoed_database_operations_total", operation="readiness", result="failure") + metrics.observe("echoed_database_operation_duration_ms", duration_ms, operation="readiness") + emit_event("database.connection.failed", level=40, component="database", operation="readiness", result="failure") + return JSONResponse( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Database is unavailable.", - ) from exc - return {"status": "ready", "database": "available"} + content={"status": "not_ready", "dependencies": {"database": "unavailable"}}, + ) + duration_ms = (time.perf_counter() - started_at) * 1000 + metrics.increment("echoed_database_operations_total", operation="readiness", result="success") + metrics.observe("echoed_database_operation_duration_ms", duration_ms, operation="readiness") + return {"status": "ready", "dependencies": {"database": "available"}} + + +@app.get("/internal/metrics", include_in_schema=False, response_class=PlainTextResponse) +def operational_metrics(x_metrics_token: str | None = Header(default=None, alias="X-Metrics-Token")): + if not settings.metrics_endpoint_enabled: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") + if not x_metrics_token or not settings.metrics_access_token or not secrets.compare_digest( + x_metrics_token, settings.metrics_access_token + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Metrics access denied") + return PlainTextResponse(metrics.render(), media_type="text/plain; version=0.0.4; charset=utf-8") diff --git a/backend/app/network_trust.py b/backend/app/network_trust.py new file mode 100644 index 0000000..db73def --- /dev/null +++ b/backend/app/network_trust.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from dataclasses import dataclass +import ipaddress +import re + +from fastapi import Request + +from app.operational_config import OperationalSettings + + +_FORWARDED_HOST = re.compile(r"^[A-Za-z0-9.-]+(?::[0-9]{1,5})?$") + + +@dataclass(frozen=True) +class NetworkContext: + client_ip: str + scheme: str + host: str + proxy_trusted: bool + + +def _peer_is_trusted(peer: str, settings: OperationalSettings) -> bool: + try: + address = ipaddress.ip_address(peer) + except ValueError: + return False + return any(address in network for network in settings.trusted_proxy_networks) + + +def resolve_network_context(request: Request, settings: OperationalSettings) -> NetworkContext: + peer = request.client.host if request.client else "unknown" + direct_host = request.headers.get("host", "unknown")[:255] + if not settings.trust_proxy_headers or not _peer_is_trusted(peer, settings): + return NetworkContext(peer, request.url.scheme, direct_host, False) + + forwarded_for = request.headers.get("x-forwarded-for", "").split(",", 1)[0].strip() + try: + client_ip = str(ipaddress.ip_address(forwarded_for)) + except ValueError: + client_ip = peer + forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower() + scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme + forwarded_host = request.headers.get("x-forwarded-host", "").split(",", 1)[0].strip() + host = forwarded_host if _FORWARDED_HOST.fullmatch(forwarded_host) else direct_host + return NetworkContext(client_ip, scheme, host, True) diff --git a/backend/app/observability.py b/backend/app/observability.py new file mode 100644 index 0000000..bbe4097 --- /dev/null +++ b/backend/app/observability.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +from collections import defaultdict +from contextvars import ContextVar +from dataclasses import dataclass +from datetime import datetime, timezone +import json +import logging +import os +import re +from threading import Lock +import traceback +from typing import Any, Mapping + + +REDACTED = "[REDACTED]" +_SENSITIVE_KEY_PARTS = ( + "authorization", + "cookie", + "password", + "passwd", + "secret", + "token", + "api_key", + "apikey", + "credential", +) +_BEARER_PATTERN = re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+\-/]+=*") +_JWT_PATTERN = re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b") + +request_id_context: ContextVar[str | None] = ContextVar("request_id", default=None) +correlation_id_context: ContextVar[str | None] = ContextVar("correlation_id", default=None) + + +def _boolean(name: str, default: bool, environ: Mapping[str, str]) -> bool: + raw = environ.get(name) + if raw is None: + return default + normalized = raw.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise RuntimeError(f"{name} must be true or false") + + +def _positive_float(name: str, default: float, environ: Mapping[str, str]) -> float: + raw = environ.get(name) + if raw is None: + return default + try: + value = float(raw) + except ValueError as exc: + raise RuntimeError(f"{name} must be a positive number") from exc + if value <= 0: + raise RuntimeError(f"{name} must be a positive number") + return value + + +@dataclass(frozen=True) +class ObservabilitySettings: + environment: str + log_level: str + log_format: str + request_logging: bool + metrics_enabled: bool + metrics_endpoint_enabled: bool + metrics_access_token: str | None + slow_request_threshold_ms: float + correlation_header: str + readiness_timeout_seconds: float + + +def load_settings(environ: Mapping[str, str] | None = None) -> ObservabilitySettings: + values = os.environ if environ is None else environ + level = values.get("LOG_LEVEL", "INFO").strip().upper() + if level not in logging._nameToLevel or level == "NOTSET": + raise RuntimeError("LOG_LEVEL must be a supported logging level") + log_format = values.get("LOG_FORMAT", "developer").strip().lower() + if log_format not in {"developer", "json"}: + raise RuntimeError("LOG_FORMAT must be 'developer' or 'json'") + endpoint_enabled = _boolean("METRICS_ENDPOINT_ENABLED", False, values) + access_token = values.get("METRICS_ACCESS_TOKEN") or None + if endpoint_enabled and not access_token: + raise RuntimeError("METRICS_ACCESS_TOKEN is required when metrics export is enabled") + header = values.get("CORRELATION_HEADER", "X-Correlation-ID").strip() + if not re.fullmatch(r"[A-Za-z0-9-]{1,64}", header): + raise RuntimeError("CORRELATION_HEADER must be a safe HTTP header name") + return ObservabilitySettings( + environment=values.get("APP_ENV", "development").strip() or "development", + log_level=level, + log_format=log_format, + request_logging=_boolean("REQUEST_LOGGING_ENABLED", True, values), + metrics_enabled=_boolean("METRICS_ENABLED", True, values), + metrics_endpoint_enabled=endpoint_enabled, + metrics_access_token=access_token, + slow_request_threshold_ms=_positive_float("SLOW_REQUEST_THRESHOLD_MS", 1000.0, values), + correlation_header=header, + readiness_timeout_seconds=_positive_float("READINESS_TIMEOUT_SECONDS", 2.0, values), + ) + + +settings = load_settings() + + +def _sensitive_key(key: object) -> bool: + normalized = str(key).strip().lower().replace("-", "_") + return any(part in normalized for part in _SENSITIVE_KEY_PARTS) + + +def redact(value: Any, *, key: object | None = None) -> Any: + if key is not None and _sensitive_key(key): + return REDACTED + if isinstance(value, Mapping): + return {str(item_key): redact(item_value, key=item_key) for item_key, item_value in value.items()} + if isinstance(value, (list, tuple, set, frozenset)): + return [redact(item) for item in value] + if isinstance(value, bytes): + return f"" + if isinstance(value, str): + return _JWT_PATTERN.sub(REDACTED, _BEARER_PATTERN.sub(REDACTED, value))[:1024] + if value is None or isinstance(value, (bool, int, float)): + return value + return str(value)[:256] + + +class StructuredFormatter(logging.Formatter): + def __init__(self, *, json_output: bool) -> None: + super().__init__() + self.json_output = json_output + + def format(self, record: logging.LogRecord) -> str: + fields = redact(getattr(record, "event_fields", {})) + payload = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "severity": record.levelname.lower(), + "event": getattr(record, "event_name", record.getMessage()), + "message": redact(record.getMessage()), + "service": "echoed-api", + "component": getattr(record, "component", "application"), + "environment": settings.environment, + "request_id": request_id_context.get(), + "correlation_id": correlation_id_context.get(), + **fields, + } + if record.exc_info: + exception_type = record.exc_info[0].__name__ if record.exc_info[0] else "Exception" + payload["exception_type"] = exception_type + payload["stack"] = [ + {"file": frame.filename[-160:], "line": frame.lineno, "function": frame.name[:80]} + for frame in traceback.extract_tb(record.exc_info[2])[-12:] + ] + payload = {key: value for key, value in payload.items() if value is not None} + if self.json_output: + return json.dumps(payload, separators=(",", ":"), sort_keys=True) + context = " ".join(f"{key}={value}" for key, value in payload.items() if key not in {"timestamp", "severity", "message"}) + return f"{payload['timestamp']} {payload['severity'].upper()} {payload['message']} {context}".rstrip() + + +def configure_logging() -> logging.Logger: + application_logger = logging.getLogger("echoed") + application_logger.setLevel(settings.log_level) + if not any(getattr(handler, "_echoed_observability", False) for handler in application_logger.handlers): + handler = logging.StreamHandler() + handler._echoed_observability = True # type: ignore[attr-defined] + handler.setFormatter(StructuredFormatter(json_output=settings.log_format == "json")) + application_logger.addHandler(handler) + application_logger.propagate = True + return application_logger + + +logger = configure_logging() + + +def emit_event( + event_name: str, + *, + level: int = logging.INFO, + message: str | None = None, + component: str = "application", + exc_info: bool | BaseException | tuple | None = None, + **fields: Any, +) -> None: + logger.log( + level, + message or event_name, + extra={"event_name": event_name, "event_fields": redact(fields), "component": component}, + exc_info=exc_info, + ) + + +def _label_text(value: object) -> str: + return str(value).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")[:160] + + +class MetricRegistry: + def __init__(self) -> None: + self._lock = Lock() + self._counters: dict[tuple[str, tuple[tuple[str, str], ...]], float] = defaultdict(float) + self._gauges: dict[tuple[str, tuple[tuple[str, str], ...]], float] = defaultdict(float) + self._histograms: dict[tuple[str, tuple[tuple[str, str], ...]], tuple[int, float]] = {} + + @staticmethod + def _key(name: str, labels: Mapping[str, object]) -> tuple[str, tuple[tuple[str, str], ...]]: + if not re.fullmatch(r"[a-z][a-z0-9_:]*", name): + raise ValueError("Metric names must be stable snake_case identifiers") + forbidden = {"user_id", "course_id", "organization_id", "email", "filename", "username", "request_id"} + if forbidden.intersection(labels): + raise ValueError("Metric labels must not contain personal or high-cardinality identifiers") + return name, tuple(sorted((str(key), str(value)[:160]) for key, value in labels.items())) + + def increment(self, name: str, amount: float = 1, **labels: object) -> None: + if not settings.metrics_enabled: + return + key = self._key(name, labels) + with self._lock: + self._counters[key] += amount + + def gauge_add(self, name: str, amount: float, **labels: object) -> None: + if not settings.metrics_enabled: + return + key = self._key(name, labels) + with self._lock: + self._gauges[key] += amount + + def observe(self, name: str, value: float, **labels: object) -> None: + if not settings.metrics_enabled: + return + key = self._key(name, labels) + with self._lock: + count, total = self._histograms.get(key, (0, 0.0)) + self._histograms[key] = (count + 1, total + value) + + def clear(self) -> None: + with self._lock: + self._counters.clear() + self._gauges.clear() + self._histograms.clear() + + @staticmethod + def _line(name: str, labels: tuple[tuple[str, str], ...], value: float | int) -> str: + rendered = ",".join(f'{key}="{_label_text(label)}"' for key, label in labels) + suffix = f"{{{rendered}}}" if rendered else "" + return f"{name}{suffix} {value}" + + def render(self) -> str: + with self._lock: + lines = [self._line(name, labels, value) for (name, labels), value in sorted(self._counters.items())] + lines.extend(self._line(name, labels, value) for (name, labels), value in sorted(self._gauges.items())) + for (name, labels), (count, total) in sorted(self._histograms.items()): + lines.append(self._line(f"{name}_count", labels, count)) + lines.append(self._line(f"{name}_sum", labels, round(total, 6))) + return "\n".join(lines) + ("\n" if lines else "") + + +metrics = MetricRegistry() + + +def record_outcome(domain: str, operation: str, result: str) -> None: + metrics.increment(f"echoed_{domain}_total", operation=operation, result=result) diff --git a/backend/app/operational_backup.py b/backend/app/operational_backup.py new file mode 100644 index 0000000..99d7b61 --- /dev/null +++ b/backend/app/operational_backup.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from contextlib import closing +import hashlib +import json +from pathlib import Path +import shutil +import sqlite3 +from typing import Iterable + + +FORMAT_VERSION = 1 + + +class BackupSafetyError(RuntimeError): + pass + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _assert_test_boundary(environment: str, acknowledged_test_data: bool) -> None: + if environment.lower() in {"production", "staging"} or not acknowledged_test_data: + raise BackupSafetyError("Repository backup tooling is restricted to explicitly acknowledged development/test data") + + +def _safe_relative(path: Path) -> str: + value = path.as_posix() + if path.is_absolute() or ".." in path.parts: + raise BackupSafetyError("Backup manifest contains an unsafe path") + return value + + +@dataclass(frozen=True) +class BackupResult: + bundle: Path + files: int + bytes: int + + +def create_test_backup( + *, + database_path: Path, + storage_roots: Iterable[tuple[str, Path]], + output_dir: Path, + environment: str, + acknowledged_test_data: bool, +) -> BackupResult: + _assert_test_boundary(environment, acknowledged_test_data) + if output_dir.exists(): + raise BackupSafetyError("Backup output must not already exist") + if not database_path.is_file(): + raise BackupSafetyError("SQLite source database is unavailable") + output_dir.mkdir(parents=True) + database_output = output_dir / "database.sqlite3" + with closing(sqlite3.connect(database_path)) as source, closing(sqlite3.connect(database_output)) as target: + with target: + source.backup(target) + integrity = target.execute("PRAGMA integrity_check").fetchone() + if not integrity or integrity[0] != "ok": + raise BackupSafetyError("SQLite backup integrity verification failed") + + files: list[dict[str, object]] = [] + assets_root = output_dir / "uploads" + for category, root in storage_roots: + if not re_safe_category(category): + raise BackupSafetyError("Storage category is invalid") + if not root.exists(): + continue + for source_file in sorted(path for path in root.rglob("*") if path.is_file()): + relative = source_file.relative_to(root) + destination = assets_root / category / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_file, destination) + bundle_relative = destination.relative_to(output_dir) + files.append({ + "path": _safe_relative(bundle_relative), + "sha256": _sha256(destination), + "bytes": destination.stat().st_size, + }) + files.insert(0, { + "path": "database.sqlite3", + "sha256": _sha256(database_output), + "bytes": database_output.stat().st_size, + }) + manifest = { + "format_version": FORMAT_VERSION, + "created_at": datetime.now(timezone.utc).isoformat(), + "data_class": "non-production-operational-drill", + "files": files, + } + (output_dir / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8") + verify_backup(output_dir) + return BackupResult(output_dir, len(files), sum(int(item["bytes"]) for item in files)) + + +def re_safe_category(value: str) -> bool: + return bool(value) and all(character.isalnum() or character in {"-", "_"} for character in value) + + +def verify_backup(bundle: Path) -> dict[str, object]: + manifest_path = bundle / "manifest.json" + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise BackupSafetyError("Backup manifest is unavailable or invalid") from exc + if manifest.get("format_version") != FORMAT_VERSION or manifest.get("data_class") != "non-production-operational-drill": + raise BackupSafetyError("Backup manifest format or data class is unsupported") + files = manifest.get("files") + if not isinstance(files, list) or not files: + raise BackupSafetyError("Backup manifest has no files") + for entry in files: + if not isinstance(entry, dict) or not isinstance(entry.get("path"), str): + raise BackupSafetyError("Backup manifest file entry is invalid") + relative = Path(_safe_relative(Path(entry["path"]))) + source = bundle / relative + if not source.is_file() or _sha256(source) != entry.get("sha256") or source.stat().st_size != entry.get("bytes"): + raise BackupSafetyError("Backup integrity verification failed") + return manifest + + +def restore_test_backup( + *, + bundle: Path, + database_target: Path, + storage_target: Path, + environment: str, + acknowledged_test_data: bool, +) -> BackupResult: + _assert_test_boundary(environment, acknowledged_test_data) + manifest = verify_backup(bundle) + if database_target.exists() or storage_target.exists(): + raise BackupSafetyError("Restore targets must not already exist") + database_target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(bundle / "database.sqlite3", database_target) + uploads = bundle / "uploads" + if uploads.exists(): + shutil.copytree(uploads, storage_target) + else: + storage_target.mkdir(parents=True) + with closing(sqlite3.connect(database_target)) as restored: + integrity = restored.execute("PRAGMA integrity_check").fetchone() + if not integrity or integrity[0] != "ok": + raise BackupSafetyError("Restored SQLite database failed integrity verification") + files = manifest["files"] + return BackupResult(bundle, len(files), sum(int(item["bytes"]) for item in files)) diff --git a/backend/app/operational_config.py b/backend/app/operational_config.py new file mode 100644 index 0000000..f2e2411 --- /dev/null +++ b/backend/app/operational_config.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from dataclasses import dataclass +import ipaddress +import os +from pathlib import Path +import re +from typing import Mapping +from urllib.parse import urlparse + +from dotenv import load_dotenv + + +class OperationalConfigurationError(RuntimeError): + """Raised when runtime configuration is unsafe or internally inconsistent.""" + + +_ENVIRONMENTS = {"development", "test", "staging", "production"} +_UNSAFE_SECRETS = {"secret", "testsecret", "changeme", "change-me", "development-secret"} +_UNSAFE_DATABASE_MARKERS = ("your_secure_password", ":postgres@", ":password@") +_HOST_PATTERN = re.compile(r"^(?:\*\.)?[A-Za-z0-9.-]+(?::[0-9]{1,5})?$") + + +def _boolean(name: str, default: bool, values: Mapping[str, str]) -> bool: + raw = values.get(name) + if raw is None: + return default + normalized = raw.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise OperationalConfigurationError(f"{name}: expected true or false") + + +def _positive_int(name: str, default: int, values: Mapping[str, str]) -> int: + raw = values.get(name) + if raw is None: + return default + try: + result = int(raw) + except ValueError as exc: + raise OperationalConfigurationError(f"{name}: expected a positive integer") from exc + if result <= 0: + raise OperationalConfigurationError(f"{name}: expected a positive integer") + return result + + +def _csv(name: str, raw: str) -> tuple[str, ...]: + values = tuple(item.strip() for item in raw.split(",") if item.strip()) + if not values: + raise OperationalConfigurationError(f"{name}: at least one value is required") + return values + + +def _origin(name: str, raw: str, *, require_https: bool) -> str: + parsed = urlparse(raw) + if parsed.scheme not in ({"https"} if require_https else {"http", "https"}): + raise OperationalConfigurationError(f"{name}: expected an absolute {'HTTPS' if require_https else 'HTTP(S)'} URL") + if not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment: + raise OperationalConfigurationError(f"{name}: expected a credential-free absolute origin") + if parsed.path not in {"", "/"}: + raise OperationalConfigurationError(f"{name}: URL paths are not allowed") + return raw.rstrip("/") + + +@dataclass(frozen=True) +class OperationalSettings: + environment: str + database_url: str + jwt_secret: str + allowed_hosts: tuple[str, ...] + allowed_origins: tuple[str, ...] + external_base_url: str | None + trust_proxy_headers: bool + trusted_proxy_networks: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] + storybook_path: Path + colorings_path: Path + badges_path: Path + persistent_storage_acknowledged: bool + auto_migrate_on_startup: bool + release_version: str | None + deployment_id: str | None + graceful_shutdown_seconds: int + + +def load_operational_settings(environ: Mapping[str, str] | None = None) -> OperationalSettings: + if environ is None: + environment_hint = os.environ.get("APP_ENV", "development").strip().lower() or "development" + if environment_hint != "production": + load_dotenv(override=False) + values: Mapping[str, str] = os.environ + else: + values = environ + + environment = values.get("APP_ENV", "development").strip().lower() or "development" + if environment not in _ENVIRONMENTS: + raise OperationalConfigurationError("APP_ENV: expected development, test, staging, or production") + production = environment == "production" + + database_url = values.get("DATABASE_URL", "").strip() + if not database_url: + if production: + raise OperationalConfigurationError("DATABASE_URL: required in production") + database_url = "sqlite:///./echoed.db" + parsed_database = urlparse(database_url) + if not parsed_database.scheme: + raise OperationalConfigurationError("DATABASE_URL: expected a valid database URL") + if production: + if parsed_database.scheme not in {"postgresql", "postgresql+psycopg2"}: + raise OperationalConfigurationError("DATABASE_URL: production requires PostgreSQL") + if any(marker in database_url.lower() for marker in _UNSAFE_DATABASE_MARKERS): + raise OperationalConfigurationError("DATABASE_URL: development/default credentials are forbidden") + + jwt_secret = values.get("JWT_SECRET", "").strip() + if not jwt_secret: + raise OperationalConfigurationError("JWT_SECRET: required") + if production and (len(jwt_secret) < 32 or jwt_secret.lower() in _UNSAFE_SECRETS): + raise OperationalConfigurationError("JWT_SECRET: production secret does not meet the safety policy") + + raw_hosts = values.get("ALLOWED_HOSTS", "localhost,127.0.0.1,testserver") + allowed_hosts = _csv("ALLOWED_HOSTS", raw_hosts) + for host in allowed_hosts: + if host == "*" or not _HOST_PATTERN.fullmatch(host): + raise OperationalConfigurationError("ALLOWED_HOSTS: contains an unsafe or malformed host") + if production and any(host.split(":", 1)[0] in {"localhost", "127.0.0.1"} for host in allowed_hosts): + raise OperationalConfigurationError("ALLOWED_HOSTS: local development hosts are forbidden in production") + + raw_origins = values.get("FRONTEND_URL", "http://localhost:4200,http://127.0.0.1:4200") + allowed_origins = tuple( + _origin("FRONTEND_URL", origin, require_https=production) + for origin in _csv("FRONTEND_URL", raw_origins) + ) + external_raw = values.get("EXTERNAL_BASE_URL", "").strip() + external_base_url = _origin("EXTERNAL_BASE_URL", external_raw, require_https=production) if external_raw else None + if production and not external_base_url: + raise OperationalConfigurationError("EXTERNAL_BASE_URL: required in production") + + trust_proxy_headers = _boolean("TRUST_PROXY_HEADERS", False, values) + proxy_entries = tuple(item.strip() for item in values.get("TRUSTED_PROXY_IPS", "").split(",") if item.strip()) + networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = [] + for entry in proxy_entries: + try: + networks.append(ipaddress.ip_network(entry, strict=False)) + except ValueError as exc: + raise OperationalConfigurationError("TRUSTED_PROXY_IPS: contains an invalid IP address or CIDR") from exc + if trust_proxy_headers and not networks: + raise OperationalConfigurationError("TRUSTED_PROXY_IPS: required when proxy headers are trusted") + if production and proxy_entries and not trust_proxy_headers: + raise OperationalConfigurationError("TRUST_PROXY_HEADERS: must explicitly enable configured proxy trust") + + paths = { + "STORYBOOK_PATH": Path(values.get("STORYBOOK_PATH", "./storybook")), + "COLORINGS_PATH": Path(values.get("COLORINGS_PATH", "./colorings")), + "BADGES_PATH": Path(values.get("BADGES_PATH", "./badges")), + } + persistent_ack = _boolean("PERSISTENT_STORAGE_ACKNOWLEDGED", False, values) + if production: + if not persistent_ack: + raise OperationalConfigurationError("PERSISTENT_STORAGE_ACKNOWLEDGED: required in production") + if any(not path.is_absolute() for path in paths.values()): + raise OperationalConfigurationError("UPLOAD_STORAGE: production paths must be absolute") + if len({str(path.resolve()) for path in paths.values()}) != len(paths): + raise OperationalConfigurationError("UPLOAD_STORAGE: storage paths must be distinct") + + auto_migrate = _boolean("AUTO_MIGRATE_ON_STARTUP", False, values) + if production and auto_migrate: + raise OperationalConfigurationError("AUTO_MIGRATE_ON_STARTUP: forbidden in production") + release_version = values.get("RELEASE_VERSION", "").strip() or None + deployment_id = values.get("DEPLOYMENT_ID", "").strip() or None + if production and (not release_version or not deployment_id): + raise OperationalConfigurationError("RELEASE_IDENTITY: RELEASE_VERSION and DEPLOYMENT_ID are required") + + if production: + if values.get("LOG_FORMAT", "").strip().lower() != "json": + raise OperationalConfigurationError("LOG_FORMAT: production requires json") + if not _boolean("METRICS_ENABLED", True, values): + raise OperationalConfigurationError("METRICS_ENABLED: production operational metrics must be enabled") + if not _boolean("REQUEST_LOGGING_ENABLED", True, values): + raise OperationalConfigurationError("REQUEST_LOGGING_ENABLED: production request diagnostics must be enabled") + if _boolean("METRICS_ENDPOINT_ENABLED", False, values) and not values.get("METRICS_ACCESS_TOKEN", "").strip(): + raise OperationalConfigurationError("METRICS_ACCESS_TOKEN: required when metrics export is enabled") + + return OperationalSettings( + environment=environment, + database_url=database_url, + jwt_secret=jwt_secret, + allowed_hosts=allowed_hosts, + allowed_origins=allowed_origins, + external_base_url=external_base_url, + trust_proxy_headers=trust_proxy_headers, + trusted_proxy_networks=tuple(networks), + storybook_path=paths["STORYBOOK_PATH"], + colorings_path=paths["COLORINGS_PATH"], + badges_path=paths["BADGES_PATH"], + persistent_storage_acknowledged=persistent_ack, + auto_migrate_on_startup=auto_migrate, + release_version=release_version, + deployment_id=deployment_id, + graceful_shutdown_seconds=_positive_int("GRACEFUL_SHUTDOWN_SECONDS", 30, values), + ) diff --git a/backend/app/rate_limit.py b/backend/app/rate_limit.py new file mode 100644 index 0000000..db3e5e1 --- /dev/null +++ b/backend/app/rate_limit.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from dataclasses import dataclass +import os +from threading import Lock +import time +from typing import Final + +from fastapi import HTTPException, Request, status + +from app.security import security_event +from app.observability import metrics + + +@dataclass(frozen=True) +class RateLimitPolicy: + limit: int + window_seconds: int + + +_DEFAULTS: Final[dict[str, RateLimitPolicy]] = { + "auth_login": RateLimitPolicy(10, 60), + "auth_register": RateLimitPolicy(5, 3600), + "invite_accept": RateLimitPolicy(10, 300), + "invite_manage": RateLimitPolicy(10, 60), + "upload": RateLimitPolicy(20, 60), + "forum_mutation": RateLimitPolicy(30, 60), + "user_management": RateLimitPolicy(20, 60), +} + + +def _positive_env(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + value = int(raw) + except ValueError as exc: + raise RuntimeError(f"{name} must be a positive integer") from exc + if value <= 0: + raise RuntimeError(f"{name} must be a positive integer") + return value + + +def get_policy(group: str) -> RateLimitPolicy: + default = _DEFAULTS.get(group) + if default is None: + raise RuntimeError(f"Unknown rate-limit group: {group}") + env_prefix = f"RATE_LIMIT_{group.upper()}" + return RateLimitPolicy( + limit=_positive_env(f"{env_prefix}_LIMIT", default.limit), + window_seconds=_positive_env(f"{env_prefix}_WINDOW_SECONDS", default.window_seconds), + ) + + +class FixedWindowRateLimiter: + def __init__(self) -> None: + self._entries: dict[tuple[str, str], tuple[int, int]] = {} + self._lock = Lock() + + def check(self, group: str, key: str, *, now: float | None = None) -> int | None: + policy = get_policy(group) + current = int(time.time() if now is None else now) + window_start = current - (current % policy.window_seconds) + storage_key = (group, key) + with self._lock: + stored_window, count = self._entries.get(storage_key, (window_start, 0)) + if stored_window != window_start: + stored_window, count = window_start, 0 + if count >= policy.limit: + return max(1, stored_window + policy.window_seconds - current) + self._entries[storage_key] = (stored_window, count + 1) + return None + + def clear(self) -> None: + with self._lock: + self._entries.clear() + + +limiter = FixedWindowRateLimiter() + + +def direct_peer_key(request: Request) -> str: + # Forwarded headers are deliberately ignored until trusted proxies are configured. + return getattr(request.state, "client_ip", None) or (request.client.host if request.client else "unknown-peer") + + +def enforce_rate_limit( + request: Request, + group: str, + *, + actor_id: object | None = None, + account_identifier: str | None = None, +) -> None: + peer = direct_peer_key(request) + if actor_id is not None: + key = f"user:{actor_id}" + elif account_identifier: + key = f"peer:{peer}:account:{account_identifier.strip().lower()}" + else: + key = f"peer:{peer}" + retry_after = limiter.check(group, key) + if retry_after is None: + return + metrics.increment("echoed_rate_limit_triggers_total", group=group) + security_event( + action="rate_limit", + result="denied", + actor_id=actor_id, + target_type="endpoint_group", + target_id=group, + reason="limit_exceeded", + request_id=getattr(request.state, "request_id", None), + ) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many requests. Please try again later.", + headers={"Retry-After": str(retry_after)}, + ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 105b37c..a5d87cc 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, Field, ConfigDict, AliasChoices, model_validator from app.enum import ProgressStatus -from typing import Any, List, Optional +from typing import Any, List, Literal, Optional from uuid import UUID from datetime import datetime @@ -23,10 +23,38 @@ class UserDto(BaseModel): username: str email: str password: str - role: str + role: Optional[str] = None model_config = ConfigDict(from_attributes=True) + +class PlatformUserSummary(BaseModel): + id: UUID + firstname: str + lastname: str + username: str + email: Optional[str] = None + role: str + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class StudentUserSummary(BaseModel): + id: UUID + firstname: str + lastname: str + username: str + role: str + + model_config = ConfigDict(from_attributes=True) + + +class PlatformUserRoleUpdate(BaseModel): + role: str + + model_config = ConfigDict(extra="forbid") + class AuthOrganizationResponse(BaseModel): id: UUID role: str @@ -414,13 +442,14 @@ class OrganizationInviteCreate(BaseModel): role: str expires_at: Optional[datetime] = None + model_config = ConfigDict(extra="forbid") + class OrganizationInviteResponse(BaseModel): id: UUID organization_id: UUID email: str role: str - token: str expires_at: datetime accepted_at: Optional[datetime] invited_by_user_id: UUID @@ -516,6 +545,24 @@ class Config: from_attributes = True +class OrganizationInviteCreatedResponse(OrganizationInviteResponse): + token: str + + +class CourseReviewDecisionRequest(BaseModel): + decision: Literal["approved", "changes_requested"] + feedback: str = Field(min_length=1, max_length=4000) + + +class CourseLifecycleResponse(BaseModel): + course_id: UUID + lifecycle_state: str + revision_number: int + version_id: Optional[UUID] = None + feedback: Optional[str] = None + changed_at: datetime + + class OrganizationSectionResponse(SectionResponse): learner_count: int = 0 teacher_count: int = 0 @@ -755,6 +802,152 @@ class CourseResponse(RevisionMetadataResponseMixin): class Config: from_attributes = True + +class CourseAuthoringCapabilitiesResponse(BaseModel): + can_create: bool = False + can_view_draft: bool = False + can_edit: bool = False + can_duplicate: bool = False + can_preview: bool = False + can_submit_review: bool = False + can_review: bool = False + can_publish: bool = False + + +class CourseAuthoringCapabilityEnvelope(BaseModel): + organization_id: Optional[UUID] = None + course_id: Optional[UUID] = None + capabilities: CourseAuthoringCapabilitiesResponse + + +class CourseAuthoringValidationIssue(BaseModel): + severity: str + entity_type: str + entity_id: Optional[UUID] = None + field: str + message: str + corrective_context: str + + +class AuthoringStorybookPageInput(BaseModel): + id: Optional[UUID] = None + image_url: str + order: Optional[int] = None + + +class AuthoringActivityInput(BaseModel): + id: Optional[UUID] = None + type: str + title: str + content: str = "" + order: Optional[int] = None + media_id: Optional[UUID] = None + pages: List[AuthoringStorybookPageInput] = Field(default_factory=list) + + +class AuthoringSourceInput(BaseModel): + id: Optional[UUID] = None + citation: str + url: Optional[str] = None + + +class AuthoringLessonInput(BaseModel): + id: Optional[UUID] = None + title: str + objective: Optional[str] = None + learning_objectives: Optional[str] = None + key_concepts: List[str] = Field(default_factory=list) + teacher_notes: Optional[str] = None + discussion_questions: List[str] = Field(default_factory=list) + hook: Optional[str] = None + content: Optional[str] = None + guided_practice: Optional[str] = None + independent_practice: Optional[str] = None + assessment: Optional[str] = None + skill_tags: List[str] = Field(default_factory=list) + standards_metadata: dict[str, Any] = Field(default_factory=dict) + order: Optional[int] = None + duration_minutes: Optional[int] = None + activities: List[AuthoringActivityInput] = Field(default_factory=list) + sources: List[AuthoringSourceInput] = Field(default_factory=list) + assessment_ids: List[UUID] = Field(default_factory=list) + + +class AuthoringUnitInput(BaseModel): + id: Optional[UUID] = None + title: str + content: Optional[str] = None + order: Optional[int] = None + lessons: List[AuthoringLessonInput] = Field(default_factory=list) + assessment_ids: List[UUID] = Field(default_factory=list) + + +class CourseAuthoringDraftRequest(BaseModel): + title: str + description: str = "" + subject: Optional[str] = None + age_band_min: Optional[int] = None + age_band_max: Optional[int] = None + default_locale: str = "en" + learning_objectives: Optional[str] = None + skill_tags: List[str] = Field(default_factory=list) + standards_metadata: dict[str, Any] = Field(default_factory=dict) + revision_number: Optional[int] = None + template_id: Optional[str] = None + units: List[AuthoringUnitInput] = Field(default_factory=list) + assessment_ids: List[UUID] = Field(default_factory=list) + + +class CourseDuplicateRequest(BaseModel): + title: Optional[str] = None + + +class CourseAuthoringDraftResponse(BaseModel): + id: UUID + title: str + description: str + subject: Optional[str] = None + age_band_min: Optional[int] = None + age_band_max: Optional[int] = None + default_locale: str + learning_objectives: Optional[str] = None + skill_tags: List[str] = Field(default_factory=list) + standards_metadata: dict[str, Any] = Field(default_factory=dict) + organization_id: Optional[UUID] = None + created_by: Optional[UUID] = None + revision_number: int + revision_status: str + revision_metadata: dict[str, Any] = Field(default_factory=dict) + updated_at: datetime + current_version_id: Optional[UUID] = None + units: List[dict[str, Any]] = Field(default_factory=list) + assessment_ids: List[UUID] = Field(default_factory=list) + capabilities: CourseAuthoringCapabilitiesResponse + + +class CourseTemplateResponse(BaseModel): + id: str + name: str + description: str + course: dict[str, Any] + + +class CourseExchangeValidationIssue(BaseModel): + severity: str + path: str + code: str + message: str + + +class CourseExchangeValidationReport(BaseModel): + format: str + can_import: bool + issues: List[CourseExchangeValidationIssue] = Field(default_factory=list) + + +class CourseImportRequest(BaseModel): + document: dict[str, Any] + class EnrollRequest(BaseModel): course_id: UUID diff --git a/backend/app/section_scope.py b/backend/app/section_scope.py index 1f50701..5a7c344 100644 --- a/backend/app/section_scope.py +++ b/backend/app/section_scope.py @@ -3,7 +3,7 @@ from fastapi import HTTPException from sqlalchemy.orm import Session -from app.models import OrganizationMembership, Section +from app.models import CourseVersion, Lesson, OrganizationMembership, Section, Unit def require_scoped_section( @@ -27,3 +27,34 @@ def require_scoped_section( if section is None: raise HTTPException(status_code=404, detail="Section not found") return section + + +def require_section_unit(db: Session, section: Section, unit_id: str | UUID) -> Unit: + try: + normalized_id = unit_id if isinstance(unit_id, UUID) else UUID(unit_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid unit id") from exc + course_version = db.get(CourseVersion, section.course_version_id) + unit = db.get(Unit, normalized_id) + if ( + unit is None + or course_version is None + or not ( + unit.course_version_id == section.course_version_id + or (unit.course_version_id is None and unit.course_id == course_version.course_id) + ) + ): + raise HTTPException(status_code=404, detail="Unit not found") + return unit + + +def require_section_lesson(db: Session, section: Section, lesson_id: str | UUID) -> Lesson: + try: + normalized_id = lesson_id if isinstance(lesson_id, UUID) else UUID(lesson_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid lesson id") from exc + lesson = db.get(Lesson, normalized_id) + if lesson is None: + raise HTTPException(status_code=404, detail="Lesson not found") + require_section_unit(db, section, lesson.unit_id) + return lesson diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..2e76370 --- /dev/null +++ b/backend/app/security.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from collections.abc import Iterable +from typing import Final +from uuid import UUID + +from fastapi import HTTPException, status + +from app.observability import emit_event, metrics + +PLATFORM_ROLES: Final[frozenset[str]] = frozenset( + {"student", "teacher", "instructor", "parent", "content_admin", "org_admin", "admin", "super_admin"} +) +PLATFORM_ADMIN_ROLES: Final[frozenset[str]] = frozenset({"admin", "super_admin"}) +HIGHEST_PLATFORM_ROLE: Final[str] = "super_admin" +FORUM_MODERATOR_ROLES: Final[frozenset[str]] = PLATFORM_ADMIN_ROLES +ORGANIZATION_ROLES: Final[frozenset[str]] = frozenset( + {"org_admin", "content_admin", "teacher", "parent", "student", "instructor", "viewer", "super_admin"} +) +ORG_ADMIN_GRANTABLE_ROLES: Final[frozenset[str]] = frozenset( + {"org_admin", "content_admin", "teacher", "parent", "student", "instructor", "viewer"} +) +PUBLIC_REGISTRATION_ROLES: Final[frozenset[str]] = frozenset( + {"student", "teacher", "instructor", "parent"} +) + + +def validate_role_allowlist(requested: Iterable[str], canonical: frozenset[str], *, scope: str) -> tuple[str, ...]: + roles = tuple(dict.fromkeys(requested)) + unknown = set(roles) - canonical + if unknown: + raise RuntimeError(f"Unknown {scope} role(s) in authorization policy: {sorted(unknown)}") + if not roles: + raise RuntimeError(f"At least one {scope} role is required") + return roles + + +def normalize_platform_role(role: str) -> str: + normalized = role.strip().lower() + if normalized not in PLATFORM_ROLES: + raise ValueError("Unsupported platform role") + return normalized + + +def can_manage_platform_target(actor_role: str, target_role: str, requested_role: str | None = None) -> bool: + if actor_role == "super_admin": + return True + if actor_role != "admin": + return False + if target_role in PLATFORM_ADMIN_ROLES: + return False + return requested_role is None or requested_role not in PLATFORM_ADMIN_ROLES + + +def require_owner_or_forum_moderator(*, actor_id: UUID, actor_role: str, owner_id: UUID) -> None: + if actor_id != owner_id and actor_role not in FORUM_MODERATOR_ROLES: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to modify this forum content.", + ) + + +def security_event( + *, + action: str, + result: str, + actor_id: UUID | str | None = None, + target_type: str | None = None, + target_id: UUID | str | None = None, + reason: str | None = None, + request_id: str | None = None, +) -> None: + event_names = { + "authentication": "auth.login.failed" if result == "denied" else "auth.login.succeeded", + "rate_limit": "rate_limit.triggered", + "upload_rejection": "upload.rejected", + } + metrics.increment("echoed_security_events_total", action=action, result=result) + emit_event( + event_names.get(action, f"security.{action}.{result}"), + component="security", + request_id=request_id, + actor_id=actor_id or "anonymous", + action=action, + target_type=target_type or "none", + target_id=target_id or "none", + result=result, + reason=reason or "none", + ) diff --git a/backend/migrate.sh b/backend/migrate.sh new file mode 100644 index 0000000..6b80a53 --- /dev/null +++ b/backend/migrate.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -e + +python -m scripts.validate_operational_config +alembic upgrade heads +python -m scripts.verify_migrations diff --git a/backend/scripts/operational_backup.py b/backend/scripts/operational_backup.py new file mode 100644 index 0000000..26dbe41 --- /dev/null +++ b/backend/scripts/operational_backup.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import argparse +import os +from pathlib import Path + +from app.operational_backup import create_test_backup, restore_test_backup, verify_backup + + +def main() -> int: + parser = argparse.ArgumentParser(description="Safe EchoEd non-production backup/restore drill tool.") + subparsers = parser.add_subparsers(dest="operation", required=True) + backup = subparsers.add_parser("backup") + backup.add_argument("--database", type=Path, required=True) + backup.add_argument("--storage", action="append", default=[], metavar="CATEGORY=PATH") + backup.add_argument("--output", type=Path, required=True) + backup.add_argument("--acknowledge-test-data", action="store_true") + verify = subparsers.add_parser("verify") + verify.add_argument("--bundle", type=Path, required=True) + restore = subparsers.add_parser("restore") + restore.add_argument("--bundle", type=Path, required=True) + restore.add_argument("--database-target", type=Path, required=True) + restore.add_argument("--storage-target", type=Path, required=True) + restore.add_argument("--acknowledge-test-data", action="store_true") + args = parser.parse_args() + environment = os.getenv("APP_ENV", "development") + if args.operation == "verify": + manifest = verify_backup(args.bundle) + print(f"Backup integrity verified ({len(manifest['files'])} file(s)).") + elif args.operation == "backup": + roots = [] + for item in args.storage: + category, separator, path = item.partition("=") + if not separator: + parser.error("--storage values must use CATEGORY=PATH") + roots.append((category, Path(path))) + result = create_test_backup( + database_path=args.database, + storage_roots=roots, + output_dir=args.output, + environment=environment, + acknowledged_test_data=args.acknowledge_test_data, + ) + print(f"Backup created and verified ({result.files} file(s), {result.bytes} bytes).") + else: + result = restore_test_backup( + bundle=args.bundle, + database_target=args.database_target, + storage_target=args.storage_target, + environment=environment, + acknowledged_test_data=args.acknowledge_test_data, + ) + print(f"Backup restored and verified ({result.files} file(s), {result.bytes} bytes).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/run_operational_drills.py b/backend/scripts/run_operational_drills.py new file mode 100644 index 0000000..8c3b61c --- /dev/null +++ b/backend/scripts/run_operational_drills.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import argparse +from contextlib import closing +from dataclasses import asdict, dataclass +import json +import os +from pathlib import Path +import sqlite3 +import subprocess +import sys +import tempfile +import time + +from fastapi.testclient import TestClient + +from app.operational_backup import create_test_backup, restore_test_backup +from app.operational_config import OperationalConfigurationError, load_operational_settings +from scripts.verify_deployment import check_endpoint + + +@dataclass(frozen=True) +class DrillResult: + name: str + passed: bool + duration_ms: float + observed: str + + +def _run(name: str, action) -> DrillResult: + started = time.perf_counter() + try: + observation = action() + return DrillResult(name, True, round((time.perf_counter() - started) * 1000, 2), observation) + except Exception as exc: + return DrillResult(name, False, round((time.perf_counter() - started) * 1000, 2), f"{type(exc).__name__}: safe drill failed") + + +def _safe_production_values(root: Path, secret: str) -> dict[str, str]: + return { + "APP_ENV": "production", + "DATABASE_URL": "postgresql://echoed:nondefault-credential@database.internal/echoed", + "JWT_SECRET": secret, + "ALLOWED_HOSTS": "api.example.edu", + "FRONTEND_URL": "https://learn.example.edu", + "EXTERNAL_BASE_URL": "https://api.example.edu", + "TRUST_PROXY_HEADERS": "false", + "STORYBOOK_PATH": str(root / "storybook"), + "COLORINGS_PATH": str(root / "colorings"), + "BADGES_PATH": str(root / "badges"), + "PERSISTENT_STORAGE_ACKNOWLEDGED": "true", + "AUTO_MIGRATE_ON_STARTUP": "false", + "RELEASE_VERSION": "drill-release", + "DEPLOYMENT_ID": "drill-deployment", + "LOG_FORMAT": "json", + "METRICS_ENABLED": "true", + "REQUEST_LOGGING_ENABLED": "true", + } + + +def invalid_configuration(root: Path) -> str: + values = _safe_production_values(root, "a-safe-synthetic-secret-at-least-32-bytes") + values["ALLOWED_HOSTS"] = "*" + try: + load_operational_settings(values) + except OperationalConfigurationError as exc: + if "ALLOWED_HOSTS" not in str(exc): + raise RuntimeError("unexpected validation category") + return "Unsafe production host configuration was rejected without values." + raise RuntimeError("unsafe configuration was accepted") + + +def migration_drill(root: Path) -> str: + backend_root = Path(__file__).resolve().parents[1] + heads = subprocess.run( + [sys.executable, "-m", "alembic", "heads"], + cwd=backend_root, + capture_output=True, + text=True, + timeout=30, + ) + if heads.returncode != 0 or "(head)" not in heads.stdout: + raise RuntimeError("migration graph head verification failed") + start_script = (backend_root / "start.sh").read_text(encoding="utf-8") + if "alembic upgrade" in start_script: + raise RuntimeError("normal startup still mutates schema") + migration_script = (backend_root / "migrate.sh").read_text(encoding="utf-8") + if "alembic upgrade heads" not in migration_script or "verify_migrations" not in migration_script: + raise RuntimeError("explicit migration gate is incomplete") + return "Migration graph has a repository head; startup is non-mutating; explicit upgrade and head-verification gates are present." + + +def lifecycle_and_health() -> str: + import app.main as main + from sqlalchemy.exc import SQLAlchemyError + + original_database_ready = main._database_ready + + def unavailable_database() -> None: + raise SQLAlchemyError("synthetic dependency outage") + + main._database_ready = unavailable_database + try: + with TestClient(main.app) as client: + live = client.get("/health/live") + ready = client.get("/health/ready") + if live.status_code != 200 or ready.status_code != 503: + raise RuntimeError("health separation gate failed") + if "database" not in ready.json().get("dependencies", {}): + raise RuntimeError("readiness dependency was not identified") + finally: + main._database_ready = original_database_ready + return "ASGI startup/shutdown completed; liveness stayed healthy while unavailable database failed readiness." + + +def failed_postdeploy() -> str: + passed, _ = check_endpoint("http://127.0.0.1:1", "/health/ready", timeout=0.2) + if passed: + raise RuntimeError("unavailable deployment passed") + return "Unavailable readiness target stopped post-deployment verification." + + +def backup_restore_rollback_storage(root: Path) -> str: + database = root / "source.sqlite3" + uploads = root / "uploads" + uploads.mkdir() + (uploads / "asset.bin").write_bytes(b"operational-drill-asset") + with closing(sqlite3.connect(database)) as connection: + with connection: + connection.execute("CREATE TABLE release_state (version TEXT NOT NULL)") + connection.execute("INSERT INTO release_state VALUES ('known-good')") + bundle = root / "backup" + create_test_backup( + database_path=database, + storage_roots=[("uploads", uploads)], + output_dir=bundle, + environment="test", + acknowledged_test_data=True, + ) + restored_database = root / "restored.sqlite3" + restored_storage = root / "restored-storage" + restore_test_backup( + bundle=bundle, + database_target=restored_database, + storage_target=restored_storage, + environment="test", + acknowledged_test_data=True, + ) + with closing(sqlite3.connect(restored_database)) as connection: + state = connection.execute("SELECT version FROM release_state").fetchone()[0] + if state != "known-good" or (restored_storage / "uploads" / "asset.bin").read_bytes() != b"operational-drill-asset": + raise RuntimeError("restored state unusable") + return "Backup integrity, isolated restore, known-good data rollback, and upload recovery succeeded." + + +def rotation_simulation(root: Path) -> str: + old = load_operational_settings(_safe_production_values(root, "old-synthetic-secret-at-least-32-characters")) + replacement = load_operational_settings(_safe_production_values(root, "new-synthetic-secret-at-least-32-characters")) + if old.jwt_secret == replacement.jwt_secret: + raise RuntimeError("rotation did not change credential") + return "Old and replacement configurations validated independently; secret values were not emitted." + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run isolated EchoEd operational-readiness drills.") + parser.add_argument("--output", type=Path, help="Optional JSON evidence path outside the temporary drill workspace.") + args = parser.parse_args() + if os.getenv("APP_ENV", "development").strip().lower() in {"production", "staging"}: + print("Operational drills refuse production and staging environments.", file=sys.stderr) + return 5 + drill_parent = Path(__file__).resolve().parents[1] / ".pytest_tmp" + drill_parent.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(prefix="echoed-operational-drill-", dir=drill_parent) as directory: + root = Path(directory) + results = [ + _run("invalid-production-configuration", lambda: invalid_configuration(root)), + _run("database-migration", lambda: migration_drill(root)), + _run("startup-health-graceful-shutdown", lifecycle_and_health), + _run("failed-postdeploy-verification", failed_postdeploy), + _run("backup-restore-rollback-storage", lambda: backup_restore_rollback_storage(root)), + _run("secret-configuration-rotation", lambda: rotation_simulation(root)), + ] + payload = { + "environment": "isolated-non-production", + "results": [asdict(result) for result in results], + "passed": all(result.passed for result in results), + } + rendered = json.dumps(payload, indent=2) + print(rendered) + if args.output: + args.output.write_text(rendered + "\n", encoding="utf-8") + return 0 if payload["passed"] else 6 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/validate_operational_config.py b/backend/scripts/validate_operational_config.py new file mode 100644 index 0000000..81841d8 --- /dev/null +++ b/backend/scripts/validate_operational_config.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import sys + +from app.operational_config import OperationalConfigurationError, load_operational_settings + + +def main() -> int: + try: + settings = load_operational_settings() + except OperationalConfigurationError as exc: + print(f"Operational configuration invalid: {exc}", file=sys.stderr) + return 2 + print(f"Operational configuration valid for {settings.environment}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/verify_deployment.py b/backend/scripts/verify_deployment.py new file mode 100644 index 0000000..42d9881 --- /dev/null +++ b/backend/scripts/verify_deployment.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import argparse +import json +import sys +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +def check_endpoint(base_url: str, path: str, expected_status: int = 200, timeout: float = 5.0) -> tuple[bool, str]: + try: + with urlopen(Request(f"{base_url.rstrip('/')}{path}", headers={"Accept": "application/json"}), timeout=timeout) as response: + body = json.loads(response.read().decode("utf-8")) + if response.status != expected_status: + return False, f"{path}: unexpected status" + return True, str(body.get("status", "ok")) + except (HTTPError, URLError, TimeoutError, ValueError): + return False, f"{path}: unavailable or invalid response" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify EchoEd post-deployment health gates.") + parser.add_argument("--base-url", required=True) + parser.add_argument("--timeout", type=float, default=5.0) + args = parser.parse_args() + for path in ("/health/live", "/health/ready"): + passed, detail = check_endpoint(args.base_url, path, timeout=args.timeout) + print(f"{'PASS' if passed else 'FAIL'} {path}: {detail}") + if not passed: + return 4 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/verify_migrations.py b/backend/scripts/verify_migrations.py new file mode 100644 index 0000000..ef46e27 --- /dev/null +++ b/backend/scripts/verify_migrations.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import sys + +from alembic.config import Config +from alembic.runtime.migration import MigrationContext +from alembic.script import ScriptDirectory + +from app.database import engine + + +def verify_migration_heads() -> tuple[set[str], set[str]]: + config = Config("alembic.ini") + script = ScriptDirectory.from_config(config) + expected = set(script.get_heads()) + with engine.connect() as connection: + actual = set(MigrationContext.configure(connection).get_current_heads()) + return expected, actual + + +def main() -> int: + expected, actual = verify_migration_heads() + if actual != expected: + print("Database migration state does not match repository heads.", file=sys.stderr) + return 3 + print(f"Database migration state verified ({len(actual)} head(s)).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/start.sh b/backend/start.sh index 01dd720..1a06b1f 100644 --- a/backend/start.sh +++ b/backend/start.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash set -e -# Run migrations on deploy/start -alembic upgrade head +# Production schema changes are an explicit release step. Validate before the +# application module is imported, and never let normal startup mutate schema. +python -m scripts.validate_operational_config -# Start API on Railway-provided port -uvicorn app.main:app --host 0.0.0.0 --port "${PORT:-8000}" +# Proxy headers are resolved by EchoEd only for explicitly configured peers. +uvicorn app.main:app --host 0.0.0.0 --port "${PORT:-8000}" --no-proxy-headers --timeout-graceful-shutdown "${GRACEFUL_SHUTDOWN_SECONDS:-30}" diff --git a/backend/tests/test_admin_users.py b/backend/tests/test_admin_users.py index b5b6da6..6408700 100644 --- a/backend/tests/test_admin_users.py +++ b/backend/tests/test_admin_users.py @@ -54,14 +54,7 @@ def test_admin_can_update_user_role_by_uuid_without_changing_password(db_session try: response = client.put( f"/api/users/{student.id}", - json={ - "firstname": student.firstname, - "lastname": student.lastname, - "username": student.username, - "email": student.email, - "password": "", - "role": "teacher", - }, + json={"role": "teacher"}, ) finally: app.dependency_overrides.clear() diff --git a/backend/tests/test_course_authoring_capabilities.py b/backend/tests/test_course_authoring_capabilities.py new file mode 100644 index 0000000..40eb200 --- /dev/null +++ b/backend/tests/test_course_authoring_capabilities.py @@ -0,0 +1,230 @@ +import uuid + +import pytest +from fastapi.testclient import TestClient + +from app.auth import get_current_user +from app.database import SessionLocal +from app.enum import MembershipStatus, OrganizationRole, OrganizationType +from app.main import app +from app.lesson_governance import serialize_course +from app.models import Course, Lesson, Organization, OrganizationMembership, Source, Unit, User + + +client = TestClient(app) + + +@pytest.fixture +def authoring_db(): + db = SessionLocal() + try: + yield db + finally: + app.dependency_overrides = {} + db.close() + + +def _user(db, role: str) -> User: + value = uuid.uuid4() + user = User( + id=value, + firstname=role.title(), + lastname="Authoring", + username=f"{role}_{value}", + email=f"{role}_{value}@example.com", + hashed_password="fake", + role=role, + ) + db.add(user) + db.commit() + return user + + +def _organization(db) -> Organization: + organization = Organization( + id=uuid.uuid4(), + name=f"Authoring Org {uuid.uuid4()}", + type=OrganizationType.SCHOOL, + ) + db.add(organization) + db.commit() + return organization + + +def _membership(db, user: User, organization: Organization, role: OrganizationRole): + membership = OrganizationMembership( + organization_id=organization.id, + user_id=user.id, + role=role, + status=MembershipStatus.ACTIVE, + ) + db.add(membership) + db.commit() + return membership + + +def _capabilities(user: User, *, org_id=None, course_id=None): + app.dependency_overrides[get_current_user] = lambda: user + headers = {"X-Org-Id": str(org_id)} if org_id else {} + path = ( + f"/api/courses/{course_id}/authoring-capabilities" + if course_id + else "/api/course-authoring/capabilities" + ) + response = client.get(path, headers=headers) + assert response.status_code == 200 + return response.json()["capabilities"] + + +def test_role_and_scope_capability_matrix(authoring_db): + organization = _organization(authoring_db) + content_admin = _user(authoring_db, "content_admin") + org_admin = _user(authoring_db, "org_admin") + teacher = _user(authoring_db, "teacher") + student = _user(authoring_db, "student") + creator = _user(authoring_db, "content_admin") + _membership(authoring_db, content_admin, organization, OrganizationRole.CONTENT_ADMIN) + _membership(authoring_db, org_admin, organization, OrganizationRole.ORG_ADMIN) + _membership(authoring_db, teacher, organization, OrganizationRole.TEACHER) + + course = Course( + id=uuid.uuid4(), + title="Scoped draft", + description="Capability matrix", + organization_id=organization.id, + created_by=creator.id, + revision_status="draft", + ) + authoring_db.add(course) + authoring_db.commit() + + content_collection = _capabilities(content_admin, org_id=organization.id) + assert content_collection == { + "can_create": True, + "can_view_draft": True, + "can_edit": True, + "can_duplicate": True, + "can_preview": True, + "can_submit_review": True, + "can_review": False, + "can_publish": False, + } + + admin_course = _capabilities(org_admin, course_id=course.id) + assert all(admin_course.values()) + + teacher_course = _capabilities(teacher, course_id=course.id) + assert teacher_course["can_duplicate"] is True + assert teacher_course["can_edit"] is False + assert teacher_course["can_publish"] is False + + student_course = _capabilities(student, course_id=course.id) + assert not any(student_course.values()) + + +def test_org_admin_cannot_review_or_publish_own_course(authoring_db): + organization = _organization(authoring_db) + org_admin = _user(authoring_db, "org_admin") + _membership(authoring_db, org_admin, organization, OrganizationRole.ORG_ADMIN) + course = Course( + title="Admin-authored draft", + description="Independent review", + organization_id=organization.id, + created_by=org_admin.id, + revision_status="submitted", + ) + authoring_db.add(course) + authoring_db.commit() + + capabilities = _capabilities(org_admin, course_id=course.id) + assert capabilities["can_edit"] is True + assert capabilities["can_review"] is False + assert capabilities["can_publish"] is False + + +def test_inactive_membership_grants_no_authoring_actions(authoring_db): + organization = _organization(authoring_db) + user = _user(authoring_db, "content_admin") + membership = OrganizationMembership( + organization_id=organization.id, + user_id=user.id, + role=OrganizationRole.CONTENT_ADMIN, + status=MembershipStatus.INACTIVE, + ) + authoring_db.add(membership) + authoring_db.commit() + + capabilities = _capabilities(user, org_id=organization.id) + assert not any(capabilities.values()) + + +def test_client_claimed_capabilities_do_not_authorize_student(authoring_db): + student = _user(authoring_db, "student") + app.dependency_overrides[get_current_user] = lambda: student + + response = client.post( + "/api/courses", + json={ + "title": "Unauthorized", + "description": "Client claims are ignored", + "units": [], + "capabilities": {"can_create": True, "can_publish": True}, + }, + ) + + assert response.status_code == 403 + assert authoring_db.query(Course).filter_by(title="Unauthorized").first() is None + + +def test_authoring_changes_preserve_learner_draft_and_teacher_note_filtering(authoring_db): + course = Course(title="Learner-safe course", description="Regression") + authoring_db.add(course) + authoring_db.flush() + unit = Unit(course_id=course.id, title="Unit", order=1) + authoring_db.add(unit) + authoring_db.flush() + approved = Lesson( + unit_id=unit.id, + title="Approved lesson", + objective="Compare two primary sources.", + learning_objectives="Learners will compare two primary sources.", + key_concepts=["evidence"], + teacher_notes="This must never reach learners.", + discussion_questions=["Which detail is strongest?"], + hook="Inspect the two accounts.", + content="Two source accounts with contextual framing.", + guided_practice="Model one comparison with evidence.", + independent_practice="Complete a second comparison.", + assessment="Submit an evidence-based comparison.", + review_status="approved", + order=1, + ) + draft = Lesson( + unit_id=unit.id, + title="Draft lesson", + objective="Not deliverable", + teacher_notes="Draft-only note", + review_status="draft", + order=2, + ) + authoring_db.add_all([approved, draft]) + authoring_db.flush() + authoring_db.add( + Source( + lesson_id=approved.id, + citation="Archive source", + url="https://example.com/source", + ) + ) + authoring_db.commit() + authoring_db.expire_all() + + serialized = serialize_course( + authoring_db.query(Course).filter(Course.id == course.id).one(), + viewer_role="student", + ) + + lessons = serialized.units[0].lessons + assert [lesson.title for lesson in lessons] == ["Approved lesson"] + assert lessons[0].teacher_notes is None + assert lessons[0].review_status is None diff --git a/backend/tests/test_course_authoring_drafts.py b/backend/tests/test_course_authoring_drafts.py new file mode 100644 index 0000000..45b7e66 --- /dev/null +++ b/backend/tests/test_course_authoring_drafts.py @@ -0,0 +1,336 @@ +import uuid +from time import perf_counter + +import pytest +from fastapi.testclient import TestClient + +from app.auth import get_current_user +from app.database import SessionLocal +from app.enum import MembershipStatus, OrganizationRole, OrganizationType +from app.main import app +from app.lesson_governance import serialize_course +from app.models import Course, Organization, OrganizationMembership, User + + +client = TestClient(app) + + +@pytest.fixture +def draft_context(): + db = SessionLocal() + suffix = uuid.uuid4() + user = User( + id=uuid.uuid4(), + firstname="Content", + lastname="Author", + username=f"draft_author_{suffix}", + email=f"draft_author_{suffix}@example.com", + hashed_password="fake", + role="content_admin", + ) + organization = Organization( + id=uuid.uuid4(), + name=f"Draft Org {suffix}", + type=OrganizationType.SCHOOL, + ) + db.add_all([user, organization]) + db.flush() + db.add( + OrganizationMembership( + organization_id=organization.id, + user_id=user.id, + role=OrganizationRole.CONTENT_ADMIN, + status=MembershipStatus.ACTIVE, + ) + ) + db.commit() + app.dependency_overrides[get_current_user] = lambda: user + try: + yield db, user, organization + finally: + app.dependency_overrides = {} + db.close() + + +def _headers(organization, key="draft-key"): + return {"X-Org-Id": str(organization.id), "Idempotency-Key": key} + + +def _payload(title="Durable course"): + return { + "title": title, + "description": "A complete nested draft", + "subject": "History", + "age_band_min": 10, + "age_band_max": 13, + "default_locale": "en", + "learning_objectives": "Compare historical sources.", + "skill_tags": ["source-analysis"], + "standards_metadata": {"framework": "local"}, + "units": [ + { + "title": "Second in supplied metadata", + "order": 9, + "lessons": [ + { + "title": "Source comparison", + "order": 8, + "duration_minutes": 30, + "teacher_notes": "Educator-only guidance", + "sources": [ + {"citation": "Archive A", "url": "https://example.com/a"} + ], + "activities": [ + { + "type": "text", + "title": "Read the accounts", + "content": "Account A and Account B", + "order": 6, + } + ], + } + ], + }, + {"title": "Second unit", "order": 2, "lessons": []}, + ], + } + + +def test_idempotent_creation_persists_one_ordered_course_graph(draft_context): + db, user, organization = draft_context + first = client.post( + "/api/courses/authoring", + headers=_headers(organization, "same-request"), + json=_payload(), + ) + second = client.post( + "/api/courses/authoring", + headers=_headers(organization, "same-request"), + json=_payload(), + ) + + assert first.status_code == 200, first.text + assert second.status_code == 200, second.text + assert first.json()["id"] == second.json()["id"] + assert db.query(Course).filter(Course.created_by == user.id).count() == 1 + payload = first.json() + assert payload["revision_number"] == 1 + assert [unit["order"] for unit in payload["units"]] == [1, 2] + assert payload["units"][0]["lessons"][0]["order"] == 1 + assert payload["units"][0]["lessons"][0]["activities"][0]["order"] == 1 + assert payload["current_version_id"] + + +def test_update_preserves_ids_and_rejects_stale_revision(draft_context): + _, _, organization = draft_context + created = client.post( + "/api/courses/authoring", + headers=_headers(organization, "revision-test"), + json=_payload(), + ).json() + course_id = created["id"] + original_ids = [unit["id"] for unit in created["units"]] + update = _payload("Updated durable course") + update["revision_number"] = created["revision_number"] + update["units"] = list(reversed(created["units"])) + + saved = client.put(f"/api/courses/{course_id}/authoring-draft", json=update) + assert saved.status_code == 200, saved.text + saved_payload = saved.json() + assert saved_payload["revision_number"] == 2 + assert [unit["id"] for unit in saved_payload["units"]] == list(reversed(original_ids)) + assert [unit["order"] for unit in saved_payload["units"]] == [1, 2] + + stale = client.put(f"/api/courses/{course_id}/authoring-draft", json=update) + assert stale.status_code == 409 + assert stale.json()["detail"]["code"] == "course_authoring_revision_conflict" + assert stale.json()["detail"]["current_revision"] == 2 + + +def test_invalid_nested_update_returns_issues_and_rolls_back(draft_context): + db, _, organization = draft_context + created = client.post( + "/api/courses/authoring", + headers=_headers(organization, "rollback-test"), + json=_payload(), + ).json() + invalid = _payload("Changed but invalid") + invalid["revision_number"] = created["revision_number"] + invalid["units"][0]["lessons"][0]["title"] = "" + + response = client.put( + f"/api/courses/{created['id']}/authoring-draft", + json=invalid, + ) + + assert response.status_code == 422 + detail = response.json()["detail"] + assert detail["code"] == "course_authoring_validation_failed" + assert detail["issues"][0]["entity_type"] == "lesson" + assert detail["issues"][0]["field"] == "title" + db.expire_all() + stored = db.query(Course).filter(Course.id == uuid.UUID(created["id"])).one() + assert stored.title == "Durable course" + assert stored.revision_number == 1 + + +def test_course_and_nested_duplication_regenerate_ids_and_preserve_attribution(draft_context): + _, _, organization = draft_context + created = client.post( + "/api/courses/authoring", + headers=_headers(organization, "duplication-source"), + json=_payload(), + ).json() + + duplicated = client.post( + f"/api/courses/{created['id']}/duplicate", + headers={"Idempotency-Key": "duplication-copy"}, + json={"title": "Adapted copy"}, + ) + + assert duplicated.status_code == 200, duplicated.text + copy = duplicated.json() + assert copy["id"] != created["id"] + assert copy["title"] == "Adapted copy" + assert copy["revision_status"] == "draft" + assert copy["revision_metadata"]["derivative_source_course_id"] == created["id"] + assert copy["units"][0]["id"] != created["units"][0]["id"] + assert copy["units"][0]["lessons"][0]["id"] != created["units"][0]["lessons"][0]["id"] + assert ( + copy["units"][0]["lessons"][0]["activities"][0]["id"] + != created["units"][0]["lessons"][0]["activities"][0]["id"] + ) + assert copy["units"][0]["lessons"][0]["sources"][0]["citation"] == "Archive A" + assert "published_at" not in copy + + +def test_preview_review_and_safe_publish_are_separate_governed_actions(draft_context): + db, author, organization = draft_context + payload = _payload("Reviewable course") + lesson = payload["units"][0]["lessons"][0] + lesson.update({ + "objective": "Evaluate evidence.", + "learning_objectives": "Explain a claim using evidence.", + "key_concepts": ["evidence"], + "hook": "Notice one surprising detail.", + "content": "A complete learner-facing explanation.", + "guided_practice": "Model one response.", + "independent_practice": "Write one response.", + "assessment": "A short evidence check.", + }) + payload["units"] = [payload["units"][0]] + created_response = client.post( + "/api/courses/authoring", + headers=_headers(organization, "review-workflow"), + json=payload, + ) + assert created_response.status_code == 200, created_response.text + created = created_response.json() + + preview = client.get(f"/api/courses/{created['id']}/authoring-preview") + assert preview.status_code == 200, preview.text + assert preview.json()["units"][0]["lessons"][0]["teacher_notes"] is None + assert preview.json()["units"][0]["lessons"][0]["title"] == "Source comparison" + + submitted = client.post(f"/api/courses/{created['id']}/submit-review") + assert submitted.status_code == 200, submitted.text + assert submitted.json()["lifecycle_state"] == "submitted" + assert client.put(f"/api/courses/{created['id']}/authoring-draft", json={**payload, "revision_number": 1}).status_code == 403 + + reviewer = User( + id=uuid.uuid4(), firstname="Independent", lastname="Reviewer", + username=f"reviewer_{uuid.uuid4()}", email=f"reviewer_{uuid.uuid4()}@example.com", + hashed_password="fake", role="org_admin", + ) + db.add(reviewer) + db.flush() + db.add(OrganizationMembership( + organization_id=organization.id, user_id=reviewer.id, + role=OrganizationRole.ORG_ADMIN, status=MembershipStatus.ACTIVE, + )) + db.commit() + app.dependency_overrides[get_current_user] = lambda: reviewer + + returned = client.post( + f"/api/courses/{created['id']}/review", + json={"decision": "changes_requested", "feedback": "Clarify the learner directions."}, + ) + assert returned.status_code == 200, returned.text + assert returned.json()["lifecycle_state"] == "changes_requested" + + app.dependency_overrides[get_current_user] = lambda: author + payload["description"] = "A revised, complete nested draft" + payload["revision_number"] = created["revision_number"] + revised = client.put(f"/api/courses/{created['id']}/authoring-draft", json=payload) + assert revised.status_code == 200, revised.text + assert client.post(f"/api/courses/{created['id']}/submit-review").status_code == 200 + + app.dependency_overrides[get_current_user] = lambda: reviewer + reviewed = client.post( + f"/api/courses/{created['id']}/review", + json={"decision": "approved", "feedback": "Ready for learners."}, + ) + assert reviewed.status_code == 200, reviewed.text + assert reviewed.json()["lifecycle_state"] == "approved" + + published = client.post(f"/api/course-versions/{created['current_version_id']}/publish") + assert published.status_code == 200, published.text + db.expire_all() + stored = db.query(Course).filter(Course.id == uuid.UUID(created["id"])).one() + assert stored.revision_metadata["authoring_state"] == "published" + assert stored.revision_metadata["published_snapshot"]["title"] == "Reviewable course" + + app.dependency_overrides[get_current_user] = lambda: author + later_edit = payload | {"description": "Unpublished second-edition changes", "revision_number": revised.json()["revision_number"]} + later = client.put(f"/api/courses/{created['id']}/authoring-draft", json=later_edit) + assert later.status_code == 200, later.text + db.expire_all() + stored = db.query(Course).filter(Course.id == uuid.UUID(created["id"])).one() + published_versions = [version for version in stored.versions if version.status.value == "published"] + draft_versions = [version for version in stored.versions if version.status.value == "draft"] + assert len(published_versions) == 1 + assert len(draft_versions) == 1 + assert {unit.course_version_id for unit in stored.units} == {published_versions[0].id, draft_versions[0].id} + learner_projection = serialize_course(stored, viewer_role="student") + assert learner_projection.description == "A revised, complete nested draft" + assert learner_projection.revision_metadata == {} + + +def test_representative_large_graph_stays_within_autosave_bounds(draft_context): + _, _, organization = draft_context + payload = { + "title": "Large authoring graph", + "description": "Performance fixture", + "units": [ + { + "title": f"Unit {unit_index + 1}", + "lessons": [ + { + "title": f"Lesson {unit_index + 1}.{lesson_index + 1}", + "activities": [ + {"type": "reading", "title": f"Activity {activity_index + 1}", "content": "x" * 500} + for activity_index in range(3) + ], + "sources": [{"citation": "Representative source", "url": "https://example.com/source"}], + } + for lesson_index in range(5) + ], + } + for unit_index in range(20) + ], + } + started = perf_counter() + created = client.post("/api/courses/authoring", headers=_headers(organization, "large-graph"), json=payload) + create_seconds = perf_counter() - started + assert created.status_code == 200, created.text + assert create_seconds < 5 + assert len(created.content) < 1_500_000 + + update = created.json() + update["title"] = "Large authoring graph revised" + started = perf_counter() + saved = client.put(f"/api/courses/{created.json()['id']}/authoring-draft", json=update) + save_seconds = perf_counter() - started + assert saved.status_code == 200, saved.text + assert save_seconds < 5 + assert len(saved.content) < 1_500_000 diff --git a/backend/tests/test_course_exchange.py b/backend/tests/test_course_exchange.py new file mode 100644 index 0000000..ddebfe7 --- /dev/null +++ b/backend/tests/test_course_exchange.py @@ -0,0 +1,26 @@ +from app.course_exchange import echoed_json_adapter +from app.course_templates import template_catalog, template_course + + +def test_template_catalog_has_valid_authoring_graphs(): + catalog = template_catalog() + assert catalog + for template in catalog: + graph = template_course(template["id"]) + document = {"format": "echoed-json-v1", "course": graph} + assert not [issue for issue in echoed_json_adapter.validate_import(document) if issue.severity == "blocking"] + assert echoed_json_adapter.to_authoring_draft(document).units + + +def test_exchange_adapter_reports_unsupported_constructs_before_persistence(): + document = { + "format": "echoed-json-v1", + "course": {"title": "Portable course", "description": "", "units": [], "scorm_package": "unsupported.zip"}, + } + issues = echoed_json_adapter.validate_import(document) + assert any(issue.code == "unsupported_construct" and issue.path == "course.scorm_package" for issue in issues) + + +def test_exchange_adapter_rejects_unknown_format(): + issues = echoed_json_adapter.validate_import({"format": "imscc", "course": {"title": "Course", "units": []}}) + assert any(issue.code == "unsupported_format" and issue.severity == "blocking" for issue in issues) diff --git a/backend/tests/test_forum_endpoints.py b/backend/tests/test_forum_endpoints.py index 9161a83..d2afc0e 100644 --- a/backend/tests/test_forum_endpoints.py +++ b/backend/tests/test_forum_endpoints.py @@ -5,6 +5,7 @@ from fastapi.testclient import TestClient from app.api.routes import threads as threads_router, posts as posts_router from app.database import get_db +from app.deps import get_current_user from app.models import User, Thread app = FastAPI() @@ -35,10 +36,11 @@ def user(test_db): return u def test_create_and_list_threads(test_db, user): + app.dependency_overrides[get_current_user] = lambda: user client = TestClient(app) resp = client.post( "/api/forum/threads", - json={"user_id": str(user.id), "title": "First"} + json={"title": "First"} ) assert resp.status_code == 200 thread_id = resp.json()["id"] @@ -54,11 +56,12 @@ def test_create_and_list_posts(test_db, user): test_db.add(t) test_db.commit() test_db.refresh(t) + app.dependency_overrides[get_current_user] = lambda: user client = TestClient(app) resp = client.post( "/api/forum/posts", - json={"thread_id": str(t.id), "user_id": str(user.id), "content": "Hi"} + json={"thread_id": str(t.id), "content": "Hi"} ) assert resp.status_code == 200 post_id = resp.json()["id"] @@ -67,3 +70,64 @@ def test_create_and_list_posts(test_db, user): assert resp.status_code == 200 posts = resp.json() assert any(p["id"] == post_id for p in posts) + + +def test_anonymous_forum_mutations_are_rejected(test_db, user): + thread = Thread(id=uuid.uuid4(), user_id=user.id, title="Protected") + test_db.add(thread) + test_db.commit() + client = TestClient(app) + + assert client.post("/api/forum/threads", json={"title": "No"}).status_code == 401 + assert client.put(f"/api/forum/threads/{thread.id}", json={"title": "No"}).status_code == 401 + assert client.delete(f"/api/forum/threads/{thread.id}").status_code == 401 + assert client.post( + "/api/forum/posts", json={"thread_id": str(thread.id), "content": "No"} + ).status_code == 401 + + +def test_forum_ownership_is_immutable_and_non_owner_is_denied(test_db, user): + other = User( + id=uuid.uuid4(), firstname="Other", lastname="User", + username=f"other_{uuid.uuid4()}", email=f"other_{uuid.uuid4()}@example.com", + hashed_password="x", role="student", + ) + thread = Thread(id=uuid.uuid4(), user_id=user.id, title="Owned") + test_db.add_all([other, thread]) + test_db.commit() + app.dependency_overrides[get_current_user] = lambda: other + client = TestClient(app) + + denied = client.put(f"/api/forum/threads/{thread.id}", json={"title": "Stolen"}) + mass_assignment = client.put( + f"/api/forum/threads/{thread.id}", + json={"title": "Stolen", "user_id": str(other.id)}, + ) + + assert denied.status_code == 403 + assert mass_assignment.status_code == 422 + test_db.refresh(thread) + assert thread.user_id == user.id + assert thread.title == "Owned" + + +def test_platform_moderator_can_delete_another_users_post(test_db, user): + moderator = User( + id=uuid.uuid4(), firstname="Forum", lastname="Moderator", + username=f"moderator_{uuid.uuid4()}", email=f"moderator_{uuid.uuid4()}@example.com", + hashed_password="x", role="admin", + ) + thread = Thread(id=uuid.uuid4(), user_id=user.id, title="T") + test_db.add_all([moderator, thread]) + test_db.flush() + from app.models import Post + post = Post(id=uuid.uuid4(), thread_id=thread.id, user_id=user.id, content="Moderate") + test_db.add(post) + test_db.commit() + app.dependency_overrides[get_current_user] = lambda: moderator + client = TestClient(app) + + response = client.delete(f"/api/forum/posts/{post.id}") + + assert response.status_code == 200 + assert test_db.query(Post).filter(Post.id == post.id).first() is None diff --git a/backend/tests/test_observability.py b/backend/tests/test_observability.py new file mode 100644 index 0000000..b3e4bd5 --- /dev/null +++ b/backend/tests/test_observability.py @@ -0,0 +1,230 @@ +from dataclasses import replace +import json +import logging +import uuid + +from fastapi.testclient import TestClient +import pytest +from sqlalchemy.exc import OperationalError + +from app import main +from app.api.routes import courses +from app.main import app +from app.observability import ( + MetricRegistry, + StructuredFormatter, + load_settings, + metrics, + redact, +) + + +client = TestClient(app, raise_server_exceptions=False) + + +def test_observability_settings_validate_environment_configuration(): + configured = load_settings( + { + "APP_ENV": "production", + "LOG_LEVEL": "WARNING", + "LOG_FORMAT": "json", + "METRICS_ENABLED": "true", + "METRICS_ENDPOINT_ENABLED": "true", + "METRICS_ACCESS_TOKEN": "operator-secret", + "SLOW_REQUEST_THRESHOLD_MS": "250", + "CORRELATION_HEADER": "X-EchoEd-Correlation", + "READINESS_TIMEOUT_SECONDS": "1.5", + } + ) + + assert configured.environment == "production" + assert configured.log_format == "json" + assert configured.metrics_endpoint_enabled is True + assert configured.correlation_header == "X-EchoEd-Correlation" + assert configured.readiness_timeout_seconds == 1.5 + + with pytest.raises(RuntimeError): + load_settings({"LOG_LEVEL": "LOUD"}) + with pytest.raises(RuntimeError): + load_settings({"METRICS_ENDPOINT_ENABLED": "true"}) + with pytest.raises(RuntimeError): + load_settings({"REQUEST_LOGGING_ENABLED": "sometimes"}) + + +def test_log_redaction_handles_nested_headers_tokens_and_binary_content(): + jwt_value = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzdHVkZW50In0.signature" + result = redact( + { + "authorization": f"Bearer {jwt_value}", + "nested": {"password": "secret", "safe": f"prefix {jwt_value}"}, + "content": b"private upload bytes", + } + ) + + assert result["authorization"] == "[REDACTED]" + assert result["nested"]["password"] == "[REDACTED]" + assert jwt_value not in result["nested"]["safe"] + assert result["content"].startswith(" dict[str, str]: + return { + "APP_ENV": "production", + "DATABASE_URL": "postgresql://echoed:nondefault-credential@database.internal/echoed", + "JWT_SECRET": "a-production-secret-with-at-least-32-characters", + "ALLOWED_HOSTS": "learn.example.edu,api.example.edu", + "FRONTEND_URL": "https://learn.example.edu", + "EXTERNAL_BASE_URL": "https://api.example.edu", + "TRUST_PROXY_HEADERS": "true", + "TRUSTED_PROXY_IPS": "10.20.0.0/16,2001:db8::1", + "STORYBOOK_PATH": str(tmp_path / "storybook"), + "COLORINGS_PATH": str(tmp_path / "colorings"), + "BADGES_PATH": str(tmp_path / "badges"), + "PERSISTENT_STORAGE_ACKNOWLEDGED": "true", + "AUTO_MIGRATE_ON_STARTUP": "false", + "RELEASE_VERSION": "sha-0123456789ab", + "DEPLOYMENT_ID": "production-us-test-1", + "LOG_FORMAT": "json", + "METRICS_ENABLED": "true", + "REQUEST_LOGGING_ENABLED": "true", + "METRICS_ENDPOINT_ENABLED": "false", + } + + +def test_valid_production_configuration_is_explicit(tmp_path): + settings = load_operational_settings(production_environment(tmp_path)) + assert settings.environment == "production" + assert settings.allowed_hosts == ("learn.example.edu", "api.example.edu") + assert not settings.auto_migrate_on_startup + + +@pytest.mark.parametrize( + ("name", "value", "expected_category"), + [ + ("JWT_SECRET", "changeme", "JWT_SECRET"), + ("DATABASE_URL", "sqlite:///production.db", "DATABASE_URL"), + ("ALLOWED_HOSTS", "*", "ALLOWED_HOSTS"), + ("FRONTEND_URL", "http://learn.example.edu", "FRONTEND_URL"), + ("TRUSTED_PROXY_IPS", "not-a-network", "TRUSTED_PROXY_IPS"), + ("AUTO_MIGRATE_ON_STARTUP", "true", "AUTO_MIGRATE_ON_STARTUP"), + ("PERSISTENT_STORAGE_ACKNOWLEDGED", "false", "PERSISTENT_STORAGE_ACKNOWLEDGED"), + ("LOG_FORMAT", "developer", "LOG_FORMAT"), + ], +) +def test_unsafe_production_configuration_fails_closed(tmp_path, name, value, expected_category): + values = production_environment(tmp_path) + values[name] = value + with pytest.raises(OperationalConfigurationError, match=expected_category): + load_operational_settings(values) + + +def test_missing_production_release_identity_fails_without_secret_value(tmp_path): + values = production_environment(tmp_path) + del values["DEPLOYMENT_ID"] + with pytest.raises(OperationalConfigurationError) as error: + load_operational_settings(values) + assert "RELEASE_IDENTITY" in str(error.value) + assert values["JWT_SECRET"] not in str(error.value) + + +def test_development_and_test_configuration_remain_usable(tmp_path): + for environment in ("development", "test"): + settings = load_operational_settings({ + "APP_ENV": environment, + "JWT_SECRET": "testsecret", + "DATABASE_URL": "sqlite:///./test.db", + "STORYBOOK_PATH": str(tmp_path / environment / "storybook"), + }) + assert settings.environment == environment + assert "testserver" in settings.allowed_hosts + + +def _network_app(settings): + application = FastAPI() + + @application.get("/") + def context(request: Request): + return resolve_network_context(request, settings).__dict__ + + return application + + +def test_untrusted_forwarded_headers_are_not_authoritative(tmp_path): + settings = load_operational_settings({ + "APP_ENV": "test", + "JWT_SECRET": "testsecret", + "DATABASE_URL": "sqlite:///./test.db", + "TRUST_PROXY_HEADERS": "true", + "TRUSTED_PROXY_IPS": "10.0.0.0/8", + }) + with TestClient(_network_app(settings), client=("192.0.2.4", 50000)) as client: + response = client.get("/", headers={ + "X-Forwarded-For": "203.0.113.44", + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "spoof.example", + }) + assert response.json() == { + "client_ip": "192.0.2.4", + "scheme": "http", + "host": "testserver", + "proxy_trusted": False, + } + + +def test_trusted_proxy_applies_only_valid_forwarding_metadata(tmp_path): + settings = load_operational_settings({ + "APP_ENV": "test", + "JWT_SECRET": "testsecret", + "DATABASE_URL": "sqlite:///./test.db", + "TRUST_PROXY_HEADERS": "true", + "TRUSTED_PROXY_IPS": "10.0.0.0/8", + }) + with TestClient(_network_app(settings), client=("10.1.2.3", 50000)) as client: + response = client.get("/", headers={ + "X-Forwarded-For": "203.0.113.44, 10.1.2.3", + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "api.example.edu", + }) + assert response.json() == { + "client_ip": "203.0.113.44", + "scheme": "https", + "host": "api.example.edu", + "proxy_trusted": True, + } + + +def test_main_app_enforces_allowed_and_rejected_hosts(): + from app.main import app + + with TestClient(app) as client: + assert client.get("/health/live", headers={"Host": "testserver"}).status_code == 200 + assert client.get("/health/live", headers={"Host": "unexpected.invalid"}).status_code == 400 + + +def _sample_state(root: Path) -> tuple[Path, list[tuple[str, Path]]]: + database = root / "source.sqlite3" + with sqlite3.connect(database) as connection: + connection.execute("CREATE TABLE evidence (id INTEGER PRIMARY KEY, value TEXT NOT NULL)") + connection.execute("INSERT INTO evidence(value) VALUES ('operational-drill')") + badges = root / "badges" + storybook = root / "storybook" + badges.mkdir() + storybook.mkdir() + (badges / "badge.png").write_bytes(b"safe-image-fixture") + (storybook / "page.jpg").write_bytes(b"safe-story-fixture") + return database, [("badges", badges), ("storybook", storybook)] + + +def test_backup_integrity_and_restore_usability(tmp_path): + database, storage = _sample_state(tmp_path) + bundle = tmp_path / "bundle" + result = create_test_backup( + database_path=database, + storage_roots=storage, + output_dir=bundle, + environment="test", + acknowledged_test_data=True, + ) + assert result.files == 3 + assert verify_backup(bundle)["format_version"] == 1 + restored_db = tmp_path / "restored" / "database.sqlite3" + restored_uploads = tmp_path / "restored-uploads" + restore_test_backup( + bundle=bundle, + database_target=restored_db, + storage_target=restored_uploads, + environment="test", + acknowledged_test_data=True, + ) + with sqlite3.connect(restored_db) as connection: + assert connection.execute("SELECT value FROM evidence").fetchone()[0] == "operational-drill" + assert (restored_uploads / "badges" / "badge.png").read_bytes() == b"safe-image-fixture" + + +def test_corrupt_backup_fails_before_restore_targets_exist(tmp_path): + database, storage = _sample_state(tmp_path) + bundle = tmp_path / "bundle" + create_test_backup( + database_path=database, + storage_roots=storage, + output_dir=bundle, + environment="test", + acknowledged_test_data=True, + ) + (bundle / "database.sqlite3").write_bytes(b"corrupt") + target = tmp_path / "target.sqlite3" + with pytest.raises(BackupSafetyError, match="integrity"): + restore_test_backup( + bundle=bundle, + database_target=target, + storage_target=tmp_path / "uploads-restored", + environment="test", + acknowledged_test_data=True, + ) + assert not target.exists() + + +def test_backup_tool_refuses_production_and_existing_targets(tmp_path): + database, storage = _sample_state(tmp_path) + with pytest.raises(BackupSafetyError, match="restricted"): + create_test_backup( + database_path=database, + storage_roots=storage, + output_dir=tmp_path / "bundle", + environment="production", + acknowledged_test_data=True, + ) + + +def test_manifest_path_traversal_is_rejected(tmp_path): + database, storage = _sample_state(tmp_path) + bundle = tmp_path / "bundle" + create_test_backup( + database_path=database, + storage_roots=storage, + output_dir=bundle, + environment="test", + acknowledged_test_data=True, + ) + manifest_path = bundle / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["files"][0]["path"] = "../outside" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + with pytest.raises(BackupSafetyError, match="unsafe"): + verify_backup(bundle) + + +def test_lifespan_disposes_database_resources(monkeypatch): + from app.main import app, engine + + calls = [] + monkeypatch.setattr(engine, "dispose", lambda: calls.append("disposed")) + with TestClient(app) as client: + assert client.get("/health/live").json() == {"status": "live"} + assert calls == ["disposed"] diff --git a/backend/tests/test_org_admin_scope.py b/backend/tests/test_org_admin_scope.py index 2184ecf..533d3a9 100644 --- a/backend/tests/test_org_admin_scope.py +++ b/backend/tests/test_org_admin_scope.py @@ -11,9 +11,12 @@ Course, CourseVersion, Enrollment, + Lesson, + LessonSession, Organization, OrganizationMembership, Section, + Unit, User, ) @@ -178,3 +181,73 @@ def test_section_enrollment_requires_active_organization_member(db_session): assert response.status_code == 400 assert response.json()["detail"] == "User is not an active member of this organization" app.dependency_overrides.clear() + + +def test_lesson_sessions_reject_cross_organization_and_parent_mismatch(db_session): + teacher = _user("Teacher", "teacher") + own_org = _organization(db_session, "Own School") + other_org = _organization(db_session, "Other School") + db_session.add(teacher) + db_session.flush() + _membership(db_session, own_org, teacher, OrganizationRole.TEACHER) + own_section = _section(db_session, own_org, teacher, "Own Class") + other_section = _section(db_session, other_org, teacher, "Other Class") + other_version = other_section.course_version + other_unit = Unit( + id=uuid.uuid4(), course_id=other_version.course_id, + course_version_id=other_version.id, title="Other Unit", + ) + db_session.add(other_unit) + db_session.flush() + other_lesson = Lesson(id=uuid.uuid4(), unit_id=other_unit.id, title="Other Lesson") + db_session.add(other_lesson) + db_session.flush() + other_session = LessonSession( + id=uuid.uuid4(), section_id=other_section.id, + lesson_id=other_lesson.id, started_by=teacher.id, + ) + db_session.add(other_session) + db_session.commit() + + client = _client(db_session, teacher) + headers = {"X-Org-Id": str(own_org.id)} + + assert client.post( + f"/api/sections/{other_section.id}/lessons/{other_lesson.id}/start", + headers=headers, + ).status_code == 404 + assert client.post( + f"/api/sections/{own_section.id}/lessons/{other_lesson.id}/start", + headers=headers, + ).status_code == 404 + assert client.post( + f"/api/lesson-sessions/{other_session.id}/end", + headers=headers, + json={}, + ).status_code == 404 + app.dependency_overrides.clear() + + +def test_section_creation_conceals_another_organizations_course_version(db_session): + teacher = _user("Teacher", "teacher") + own_org = _organization(db_session, "Own School") + other_org = _organization(db_session, "Other School") + db_session.add(teacher) + db_session.flush() + _membership(db_session, own_org, teacher, OrganizationRole.TEACHER) + other_section = _section(db_session, other_org, teacher, "Other Class") + db_session.commit() + + client = _client(db_session, teacher) + response = client.post( + "/api/sections", + headers={"X-Org-Id": str(own_org.id)}, + json={ + "course_version_id": str(other_section.course_version_id), + "name": "Cross-org class", + "mode": "remote", + }, + ) + + assert response.status_code == 404 + app.dependency_overrides.clear() diff --git a/backend/tests/test_security_hardening.py b/backend/tests/test_security_hardening.py new file mode 100644 index 0000000..32654d6 --- /dev/null +++ b/backend/tests/test_security_hardening.py @@ -0,0 +1,389 @@ +import uuid + +from fastapi import Request +from fastapi.testclient import TestClient + +from app.auth import get_current_user +from app.database import get_db +from app.deps import get_db as deps_get_db +from app.enum import OrganizationRole, OrganizationType +from app.main import app +from app.models import Course, Organization, OrganizationMembership, StudentCourse, StudentUnitProgress, Unit, User +from app.rate_limit import FixedWindowRateLimiter, direct_peer_key, get_policy, limiter + + +def _user(role: str, prefix: str) -> User: + suffix = uuid.uuid4().hex[:8] + return User( + id=uuid.uuid4(), firstname=prefix.title(), lastname="Security", + username=f"{prefix}_{suffix}", email=f"{prefix}_{suffix}@example.test", + hashed_password="not-a-public-field", role=role, + ) + + +def _client(db_session, actor: User | None = None) -> TestClient: + app.dependency_overrides[get_db] = lambda: db_session + app.dependency_overrides[deps_get_db] = lambda: db_session + if actor is not None: + app.dependency_overrides[get_current_user] = lambda: actor + return TestClient(app) + + +def _clear() -> None: + app.dependency_overrides.clear() + limiter.clear() + + +def test_admin_user_responses_are_explicit_and_minimized(db_session): + admin = _user("admin", "admin") + student = _user("student", "student") + db_session.add_all([admin, student]) + db_session.commit() + client = _client(db_session, admin) + try: + response = client.get("/api/users") + finally: + _clear() + + assert response.status_code == 200 + assert set(response.json()[0]) == {"id", "firstname", "lastname", "username", "email", "role", "created_at"} + assert all("hashed_password" not in item and "updated_at" not in item for item in response.json()) + + +def test_user_role_update_rejects_mass_assignment_and_platform_grant(db_session): + admin = _user("admin", "admin") + student = _user("student", "student") + db_session.add_all([admin, student]) + db_session.commit() + client = _client(db_session, admin) + try: + mass = client.put( + f"/api/users/{student.id}", + json={"role": "teacher", "hashed_password": "replace-me"}, + ) + escalation = client.put(f"/api/users/{student.id}", json={"role": "super_admin"}) + finally: + _clear() + + db_session.refresh(student) + assert mass.status_code == 422 + assert escalation.status_code == 403 + assert student.role == "student" + assert student.hashed_password == "not-a-public-field" + + +def test_administrator_cannot_change_or_delete_self(db_session): + admin = _user("admin", "admin") + db_session.add(admin) + db_session.commit() + client = _client(db_session, admin) + try: + role_response = client.put(f"/api/users/{admin.id}", json={"role": "student"}) + delete_response = client.delete(f"/api/users/{admin.id}") + finally: + _clear() + + assert role_response.status_code == 409 + assert delete_response.status_code == 409 + assert db_session.query(User).filter(User.id == admin.id).first() is not None + + +def test_final_super_admin_cannot_be_demoted_or_deleted(db_session): + actor = _user("super_admin", "external_super") + final_admin = _user("super_admin", "final_super") + db_session.add(final_admin) + db_session.commit() + client = _client(db_session, actor) + try: + demote = client.put(f"/api/users/{final_admin.id}", json={"role": "admin"}) + delete = client.delete(f"/api/users/{final_admin.id}") + finally: + _clear() + + db_session.refresh(final_admin) + assert demote.status_code == 409 + assert delete.status_code == 409 + assert final_admin.role == "super_admin" + + +def test_super_admin_can_remove_another_when_multiple_remain(db_session): + actor = _user("super_admin", "actor_super") + target = _user("super_admin", "target_super") + db_session.add_all([actor, target]) + db_session.commit() + client = _client(db_session, actor) + try: + response = client.put(f"/api/users/{target.id}", json={"role": "admin"}) + finally: + _clear() + + db_session.refresh(target) + assert response.status_code == 200 + assert target.role == "admin" + + +def test_registration_ignores_requested_privileged_role(db_session): + client = _client(db_session) + suffix = uuid.uuid4().hex[:8] + try: + response = client.post( + "/api/auth/register", + json={ + "firstname": "Public", "lastname": "Registrant", + "username": f"register_{suffix}", "email": f"register_{suffix}@example.test", + "password": "secret", "role": "super_admin", + }, + ) + finally: + _clear() + + created = db_session.query(User).filter(User.username == f"register_{suffix}").one() + assert response.status_code == 200 + assert created.role == "student" + + +def test_invite_response_omits_token_and_org_admin_cannot_grant_super_admin(db_session): + admin = _user("admin", "org_admin") + organization = Organization(id=uuid.uuid4(), name="Safe Org", type=OrganizationType.SCHOOL) + db_session.add_all([admin, organization]) + db_session.flush() + db_session.add( + OrganizationMembership( + id=uuid.uuid4(), organization_id=organization.id, user_id=admin.id, + role=OrganizationRole.ORG_ADMIN, + ) + ) + db_session.commit() + client = _client(db_session, admin) + headers = {"X-Org-Id": str(organization.id)} + try: + allowed = client.post( + f"/api/orgs/{organization.id}/invites", headers=headers, + json={"email": "teacher@example.test", "role": "teacher"}, + ) + denied = client.post( + f"/api/orgs/{organization.id}/invites", headers=headers, + json={"email": "super@example.test", "role": "super_admin"}, + ) + finally: + _clear() + + assert allowed.status_code == 200, allowed.text + assert "token" in allowed.json() + listed = _client(db_session, admin).get( + f"/api/orgs/{organization.id}/invites", headers=headers + ) + _clear() + assert "token" not in listed.json()[0] + assert denied.status_code == 403 + + +def test_fixed_window_rate_limiter_has_independent_keys_and_resets(monkeypatch): + monkeypatch.setenv("RATE_LIMIT_AUTH_LOGIN_LIMIT", "2") + monkeypatch.setenv("RATE_LIMIT_AUTH_LOGIN_WINDOW_SECONDS", "10") + test_limiter = FixedWindowRateLimiter() + + assert test_limiter.check("auth_login", "a", now=100) is None + assert test_limiter.check("auth_login", "a", now=101) is None + assert test_limiter.check("auth_login", "b", now=102) is None + assert test_limiter.check("auth_login", "a", now=102) == 8 + assert test_limiter.check("auth_login", "a", now=110) is None + + +def test_rate_limit_proxy_key_ignores_untrusted_forwarded_header(): + request = Request({ + "type": "http", "method": "GET", "path": "/", "query_string": b"", + "headers": [(b"x-forwarded-for", b"203.0.113.10")], + "client": ("127.0.0.9", 12345), "server": ("test", 80), "scheme": "http", + }) + assert direct_peer_key(request) == "127.0.0.9" + + +def test_rate_limit_configuration_rejects_non_positive_values(monkeypatch): + monkeypatch.setenv("RATE_LIMIT_AUTH_LOGIN_LIMIT", "0") + try: + get_policy("auth_login") + raised = False + except RuntimeError: + raised = True + assert raised is True + + +def test_authentication_rate_limit_returns_429_and_retry_after(db_session, monkeypatch): + monkeypatch.setenv("RATE_LIMIT_AUTH_LOGIN_LIMIT", "1") + monkeypatch.setenv("RATE_LIMIT_AUTH_LOGIN_WINDOW_SECONDS", "60") + client = _client(db_session) + try: + first = client.post("/api/auth/token", data={"username": "missing", "password": "bad"}) + limited = client.post("/api/auth/token", data={"username": "missing", "password": "bad"}) + finally: + _clear() + + assert first.status_code == 401 + assert limited.status_code == 429 + assert int(limited.headers["Retry-After"]) >= 1 + assert limited.json()["detail"] == "Too many requests. Please try again later." + + +def test_inactive_membership_cannot_switch_organization(db_session): + from app.enum import MembershipStatus + user = _user("teacher", "inactive") + organization = Organization(id=uuid.uuid4(), name="Inactive Org", type=OrganizationType.SCHOOL) + db_session.add_all([user, organization]) + db_session.flush() + db_session.add( + OrganizationMembership( + id=uuid.uuid4(), organization_id=organization.id, user_id=user.id, + role=OrganizationRole.TEACHER, status=MembershipStatus.INACTIVE, + ) + ) + db_session.commit() + client = _client(db_session, user) + try: + response = client.post(f"/api/orgs/{organization.id}/switch") + finally: + _clear() + + assert response.status_code == 404 + + +def test_sensitive_user_mutation_rate_limit_returns_429(db_session, monkeypatch): + monkeypatch.setenv("RATE_LIMIT_USER_MANAGEMENT_LIMIT", "1") + monkeypatch.setenv("RATE_LIMIT_USER_MANAGEMENT_WINDOW_SECONDS", "60") + actor = _user("super_admin", "rate_super") + first_target = _user("student", "rate_first") + second_target = _user("student", "rate_second") + db_session.add_all([actor, first_target, second_target]) + db_session.commit() + client = _client(db_session, actor) + try: + first = client.put(f"/api/users/{first_target.id}", json={"role": "teacher"}) + limited = client.put(f"/api/users/{second_target.id}", json={"role": "teacher"}) + finally: + _clear() + + assert first.status_code == 200 + assert limited.status_code == 429 + + +def test_learner_cannot_read_another_learners_progress_by_direct_id(db_session): + learner_a = _user("student", "learner_a") + learner_b = _user("student", "learner_b") + course = Course(id=uuid.uuid4(), title="Protected Course", description="Scope") + unit = Unit(id=uuid.uuid4(), course_id=course.id, title="Protected Unit") + db_session.add_all([learner_a, learner_b, course, unit]) + db_session.flush() + enrollment = StudentCourse(id=uuid.uuid4(), student_id=learner_b.id, course_id=course.id) + db_session.add(enrollment) + db_session.flush() + progress = StudentUnitProgress( + id=uuid.uuid4(), student_course_id=enrollment.id, unit_id=unit.id, + ) + db_session.add(progress) + db_session.commit() + client = _client(db_session, learner_a) + try: + response = client.get(f"/api/progress/segment?student_unit_id={progress.id}") + finally: + _clear() + + assert response.status_code == 404 + + +def test_upload_signature_traversal_authorization_and_limit(db_session, tmp_path, monkeypatch): + from app.api.routes import uploads + + admin = _user("admin", "upload_admin") + student = _user("student", "upload_student") + db_session.add_all([admin, student]) + db_session.commit() + destination = tmp_path / "badges" + destination.mkdir() + monkeypatch.setattr(uploads, "BADGES_PATH", str(destination)) + valid_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + + student_client = _client(db_session, student) + unauthorized = student_client.post( + "/api/upload/badge", files={"file": ("badge.png", valid_png, "image/png")} + ) + _clear() + + admin_client = _client(db_session, admin) + mismatch = admin_client.post( + "/api/upload/badge", files={"file": ("badge.png", b"not-png", "image/png")} + ) + traversal = admin_client.post( + "/api/upload/badge", files={"file": ("../../badge.png", valid_png, "image/png")} + ) + _clear() + + assert unauthorized.status_code == 403 + assert mismatch.status_code == 415 + assert traversal.status_code == 200 + stored = list(destination.iterdir()) + assert len(stored) == 1 + assert stored[0].parent == destination + assert ".." not in stored[0].name + + +def test_upload_rate_limit_is_per_authenticated_user(db_session, tmp_path, monkeypatch): + from app.api.routes import uploads + + monkeypatch.setenv("RATE_LIMIT_UPLOAD_LIMIT", "1") + monkeypatch.setenv("RATE_LIMIT_UPLOAD_WINDOW_SECONDS", "60") + first_admin = _user("admin", "first_upload") + second_admin = _user("admin", "second_upload") + db_session.add_all([first_admin, second_admin]) + db_session.commit() + destination = tmp_path / "badges" + destination.mkdir() + monkeypatch.setattr(uploads, "BADGES_PATH", str(destination)) + image = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + + first_client = _client(db_session, first_admin) + first = first_client.post("/api/upload/badge", files={"file": ("one.png", image, "image/png")}) + limited = first_client.post("/api/upload/badge", files={"file": ("two.png", image, "image/png")}) + _clear() + second_client = _client(db_session, second_admin) + independent = second_client.post("/api/upload/badge", files={"file": ("three.png", image, "image/png")}) + _clear() + + assert first.status_code == 200 + assert limited.status_code == 429 + assert "Retry-After" in limited.headers + assert independent.status_code == 200 + + +def test_content_administrator_cannot_mutate_another_organizations_unit(db_session): + actor = _user("content_admin", "content_admin") + organization_a = Organization(id=uuid.uuid4(), name="Org A", type=OrganizationType.SCHOOL) + organization_b = Organization(id=uuid.uuid4(), name="Org B", type=OrganizationType.SCHOOL) + course_b = Course( + id=uuid.uuid4(), organization_id=organization_b.id, + title="Org B Course", description="Protected", + ) + unit_b = Unit(id=uuid.uuid4(), course_id=course_b.id, title="Org B Unit") + db_session.add_all([actor, organization_a, organization_b, course_b, unit_b]) + db_session.flush() + db_session.add( + OrganizationMembership( + id=uuid.uuid4(), organization_id=organization_a.id, user_id=actor.id, + role=OrganizationRole.CONTENT_ADMIN, + ) + ) + db_session.commit() + client = _client(db_session, actor) + try: + response = client.put( + f"/api/units/{unit_b.id}", + json={ + "course_id": str(course_b.id), "title": "Stolen", + "content": None, "order": 1, + }, + ) + finally: + _clear() + + db_session.refresh(unit_b) + assert response.status_code == 403 + assert unit_b.title == "Org B Unit" diff --git a/backend/tests/test_upload_badge.py b/backend/tests/test_upload_badge.py index 1e4ade3..6ec69ee 100644 --- a/backend/tests/test_upload_badge.py +++ b/backend/tests/test_upload_badge.py @@ -30,7 +30,7 @@ def test_upload_badge_image(db_session, tmp_path, monkeypatch): app.dependency_overrides[get_current_user] = lambda: admin_user try: - content = b"\x89PNG\r\n\x1a\n" + content = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01\x00\x00\x00\x01" files = {"file": ("badge.png", content, "image/png")} response = client.post("/api/upload/badge", files=files) assert response.status_code == 200 diff --git a/backend/tests/test_upload_storybook.py b/backend/tests/test_upload_storybook.py index ca938b8..231013d 100644 --- a/backend/tests/test_upload_storybook.py +++ b/backend/tests/test_upload_storybook.py @@ -30,7 +30,7 @@ def test_upload_storybook_page(db_session, tmp_path, monkeypatch): app.dependency_overrides[get_current_user] = lambda: teacher_user try: - content = b"\x89PNG\r\n\x1a\n" + content = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01\x00\x00\x00\x01" files = {"file": ("page.png", content, "image/png")} response = client.post("/api/upload/storybook", files=files) assert response.status_code == 200 diff --git a/docker-compose.yml b/docker-compose.yml index f659d38..c0aa816 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,31 @@ -version: "3.9" - services: + migrate: + build: ./backend + env_file: + - .env + environment: + APP_ENV: development + DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-echoed} + ALLOWED_HOSTS: backend,localhost,127.0.0.1,testserver + command: ["bash", "./migrate.sh"] + depends_on: + db: + condition: service_healthy + restart: "no" + backend: build: ./backend env_file: - .env environment: - DATABASE_URL: postgresql://$${POSTGRES_USER:-postgres}:$${POSTGRES_PASSWORD:-postgres}@db:5432/$${POSTGRES_DB:-echoed} + APP_ENV: development + DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-echoed} STORYBOOK_PATH: /data/storybook COLORINGS_PATH: /data/colorings BADGES_PATH: /data/badges FRONTEND_URL: ${FRONTEND_URL:-http://localhost:4200} + ALLOWED_HOSTS: backend,localhost,127.0.0.1,testserver + AUTO_MIGRATE_ON_STARTUP: "false" volumes: - ./backend/storybook:/data/storybook - ./backend/colorings:/data/colorings @@ -18,8 +33,15 @@ services: ports: - "8000:8000" depends_on: - - db - - minio + migrate: + condition: service_completed_successfully + init: true + stop_grace_period: 35s + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)"] + interval: 10s + timeout: 5s + retries: 6 db: image: postgres:15 @@ -32,6 +54,11 @@ services: - postgres_data:/var/lib/postgresql/data ports: - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-postgres} -d $${POSTGRES_DB:-echoed}"] + interval: 5s + timeout: 5s + retries: 12 minio: image: minio/minio diff --git a/docs/course-authoring-domain-inventory.md b/docs/course-authoring-domain-inventory.md new file mode 100644 index 0000000..45adb5d --- /dev/null +++ b/docs/course-authoring-domain-inventory.md @@ -0,0 +1,35 @@ +# Course Authoring Domain Inventory + +This inventory maps the existing EchoEd domain to the unified course-authoring aggregate. It is an implementation aid, not a second curriculum contract. + +## Canonical aggregate + +| Authoring concern | Canonical model and fields | Existing creation-path behavior | Unified behavior | +| --- | --- | --- | --- | +| Course identity | `Course.id`, `title`, `description`, `subject`, `age_band_min`, `age_band_max`, `default_locale` | Legacy wizard only sends title and description; `/courses/authoring` accepts the complete identity but creates no graph. | One aggregate accepts and returns every supported identity field. | +| Alignment metadata | `Course.learning_objectives`, `skill_tags`, `standards_metadata` | Accepted by APIs but mostly absent from UI. | Setup and quality views use the same canonical fields. | +| Ownership and scope | `Course.created_by`, `organization_id` | Legacy writes can create unscoped courses; organization authoring scopes only the shell. | Capability resolver and aggregate service enforce organization scope and creator relationship. | +| Draft revision and recovery | `Course.revision_number`, `revision_status`, `revision_metadata`, `updated_at`; `CourseVersion` | Client-side wizard state is posted at the end; course versions are disconnected from graph editing. | `revision_number` is the optimistic concurrency token; `revision_status` and `CourseVersion.status` represent lifecycle; idempotency and template origin are stored in `revision_metadata`. | +| Units | `Unit` and `Course.units` | Legacy create/update persists units and recreates the entire graph during update. | Transactional aggregate normalizes deterministic order and preserves supplied identifiers when they belong to the draft. | +| Lessons | `Lesson` and `Unit.lessons` | Legacy path persists rich lesson content and review fields. | Aggregate reuses all instructional and educator-only fields while centralizing review-field authorization. | +| Activities and storybooks | `Activity`, `StorybookPage` | Created in the wizard review step. | Edited in the persistent outline; media continues to use the canonical upload/media services. | +| Sources | `Source` | Persisted by legacy nested writes. | Persisted by the aggregate and preserved during duplication. | +| Assessments | `Assessment`, `Question`, `AssessmentCompetencyAlignment` | Separate assessment APIs and relationships. | Aggregate returns assessment references; assessment content remains owned by the existing assessment engine. | +| Review | `Lesson.review_status`, `reviewed_by`; V2 review wrappers and governance services | Review checks are disconnected from course creation. | Studio lifecycle actions call the existing review/governance boundaries; no parallel review table is introduced in the first slice. | +| Versions and publication | `CourseVersion`, `Course.published_at`, nested `published_at`, safe-publish/readiness services | Organization endpoint creates a draft version; a separate endpoint publishes it. | Every durable draft has a current version, and learner visibility changes only through governed publication. | + +## Creation-path convergence + +- `POST /api/courses` and `PUT /api/courses/{id}` are retained temporarily for compatibility and delegated to the canonical aggregate service. +- `POST /api/courses/authoring` becomes the idempotent organization-aware creation boundary. +- The shallow `StudioCoursesComponent` form and `CourseWizardComponent` are migration sources only; the canonical Angular Studio uses one authoring service and one durable draft route. +- Learner-facing course and lesson serializers remain authoritative for delivery and preview filtering. + +## Metadata reuse decisions + +- No new draft table is required for the first slice. +- `Course.revision_number` is the concurrency token. +- `Course.revision_status` carries `draft`, `submitted`, `approved`, `published`, or `changes_requested` during authoring. +- `Course.revision_metadata` stores bounded authoring metadata such as idempotency key, template origin, derivative source, last submission, and review feedback. +- `Course.updated_at` is the visible durable-save timestamp. +- A data migration is required only to normalize older editable course records to a non-null revision number/status/metadata; existing schema columns already support the contract. diff --git a/docs/course-authoring-guide.md b/docs/course-authoring-guide.md new file mode 100644 index 0000000..1f31dda --- /dev/null +++ b/docs/course-authoring-guide.md @@ -0,0 +1,55 @@ +# Course authoring operations guide + +## Role policy + +The backend capability response is the source of truth; the UI never grants an action from a claimed client role. + +| Role and scope | Author | Review | Publish | +| --- | --- | --- | --- | +| Active organization content admin | Create, edit, duplicate, preview, submit | No | No | +| Active organization admin | Create, edit, duplicate, preview, submit | Yes, except own course | Yes, except own course | +| Active teacher or instructor | Duplicate approved/published content; edit and submit owned derivatives | No | No | +| Platform admin or super admin | Full cross-organization oversight | Yes | Yes | +| Learner or inactive/out-of-scope member | No authoring capabilities | No | No | + +## Creator workflow + +1. Open Product Studio → Courses and choose a blank course or internal template. +2. Complete Setup, then build the ordered unit, lesson, activity, and source outline in Build. +3. Watch the live save status. Quality combines immediate authoring prompts with the canonical governance summary. +4. Use Preview to request the learner-safe server projection. Preview does not change learner availability. +5. Fix blocking issues, save, and submit for review. A different authorized reviewer records approval or requests changes with feedback. +6. An authorized publisher publishes only an approved version that passes safe-publish validation. Later edits return the working copy to draft while the published snapshot remains recorded on the published version metadata. + +## Save and recovery behavior + +- Draft creation uses an idempotency key, so a safe retry cannot create two courses. +- Changes autosave after a short pause and writes are serialized. `Save now` uses the same path. +- The editor retains local input after offline or server failure and exposes Retry. +- Every update includes the last server revision. A stale revision produces a conflict without overwriting either copy; creators can reload the server copy or save local work as a recovered course. +- Browser close/reload warnings appear only while local changes are not durable. Saved drafts resume from their canonical URL. + +## Accessibility and responsive verification checklist + +Automation covers component rendering, status messages, debounce behavior, production compilation, and service contracts. Before general release, verify the following on desktop, tablet, and a 320-pixel mobile viewport: + +- Complete Setup, Build, Quality, Preview, and Release using only the keyboard. +- Confirm heading order and landmark names in a screen-reader rotor. +- Confirm every input announces its label and supporting help, save and lifecycle changes announce once, and blocking issues move focus to the affected editor. +- Reorder units, lessons, and activities with Move up/Move down; confirm visual order and reading order agree. +- Confirm visible focus, 44-pixel primary targets, reflow without horizontal page scrolling, and no information conveyed by color alone. +- Trigger offline, failed save, and revision conflict states; confirm local text remains available and recovery actions are understandable. + +## Interoperability limits + +The first release exports the lossless `echoed-json-v1` aggregate and validates an EchoEd JSON document before any future import persistence. Adapter boundaries report every blocking or unsupported construct. Common Cartridge and QTI execution are intentionally deferred; do not represent those formats as supported in product copy. + +## Rollout and rollback + +Canonical navigation is controlled by `courseAuthoringStudioEnabled` in Angular environments. Pilot with authorized organization content admins and an independent org admin reviewer. Monitor failed saves, revision conflicts, validation failures, publish blocks, payload size, and save latency. + +To roll back navigation, set the flag to `false` and redeploy the frontend. Existing legacy endpoints remain compatibility adapters over the canonical aggregate service, so durable drafts remain readable. Do not delete course/version data during rollback. Re-enable only after the defect is corrected and focused authorization, draft, learner-safety, frontend, build, and browser smoke checks pass. + +## Pilot performance baseline + +The local integration benchmark persists and revises a representative 20-unit graph with 100 lessons and 300 activities. The combined create/update test completed in 0.67 seconds on the development SQLite environment; each operation is bounded at 5 seconds and each aggregate response at 1.5 MB. Production rollout should capture p50/p95 network and database latency against the same shape before widening the flag. diff --git a/docs/observability/background-work-observability.md b/docs/observability/background-work-observability.md new file mode 100644 index 0000000..ba1d1df --- /dev/null +++ b/docs/observability/background-work-observability.md @@ -0,0 +1,5 @@ +# Background Work Observability + +EchoEd currently has no Celery, RQ, scheduler, message broker, executing generation worker, or application-managed retry queue. `GenerationRun` rows with a `queued` status are product metadata, not evidence of an active worker architecture. Phase 10 therefore does not invent a queue or report queue depth. + +Any future worker must emit stable start/success/failure/duration/retry/permanent-failure events, use bounded operation/resource-type metric labels, propagate a sanitized correlation ID from the initiating request where available, generate its own execution ID, and exclude payload bodies, generated content, learner data, secrets, and filenames. Worker lifecycle persistence is distinct from the durable security audit ledger. diff --git a/docs/observability/configuration.md b/docs/observability/configuration.md new file mode 100644 index 0000000..0edf13f --- /dev/null +++ b/docs/observability/configuration.md @@ -0,0 +1,18 @@ +# Observability Configuration + +Settings are provider-neutral environment variables loaded and validated at API startup. + +| Variable | Default | Contract | +| --- | --- | --- | +| `APP_ENV` | `development` | Environment field on structured events | +| `LOG_LEVEL` | `INFO` | Standard Python level; invalid values fail startup | +| `LOG_FORMAT` | `developer` | `developer` or `json` | +| `REQUEST_LOGGING_ENABLED` | `true` | Emits one completion event per request | +| `METRICS_ENABLED` | `true` | Collects bounded process-local metrics | +| `METRICS_ENDPOINT_ENABLED` | `false` | Conceals export unless explicitly enabled | +| `METRICS_ACCESS_TOKEN` | unset | Required when metrics export is enabled; never logged | +| `SLOW_REQUEST_THRESHOLD_MS` | `1000` | Positive duration for `request.slow` | +| `READINESS_TIMEOUT_SECONDS` | `2` | Positive database readiness bound | +| `CORRELATION_HEADER` | `X-Correlation-ID` | Safe 1–64 character HTTP header name | + +Boolean values accept true/false, 1/0, yes/no, or on/off. Invalid booleans, formats, levels, positive durations, header names, or an enabled metrics endpoint without a token fail visibly at startup. Production should use JSON logs and an operator-managed metrics token/endpoint boundary. Basic diagnostics do not depend on a secret, and no setting is tied to a hosting provider. diff --git a/docs/observability/course-studio-observability.md b/docs/observability/course-studio-observability.md new file mode 100644 index 0000000..f828800 --- /dev/null +++ b/docs/observability/course-studio-observability.md @@ -0,0 +1,9 @@ +# Course Studio Observability + +The backend emits `course_studio..` at supported API boundaries and increments `echoed_course_studio_operations_total{operation,result}`. Operations include template use/listing, import validation, draft creation, draft save, revision conflict, preview, review submission/transition, duplication, publish attempt, publish blocked, and publish success. + +Course/resource identifiers may appear in restricted logs when useful for correlation, but never as metric labels. Course graphs, unit/lesson/activity content, assessment answers, imported document bodies, sources, uploaded files, feedback text, and form payloads are excluded. Existing validation details remain in normal authorized API responses, not diagnostic events. + +Angular autosave failure leaves the draft dirty and browser content intact. Unexpected save/publish failures may show the backend request reference and emit only operation, HTTP status, and request ID to the browser console. Revision conflicts and validation failures retain their deliberate workflow messaging without reference clutter. + +No version-restore endpoint currently exists; therefore Phase 10 does not invent or instrument one. Duplicate is the supported recovery-style operation. Preview failures that escape domain handling are still observable through normalized HTTP and unexpected-request signals. diff --git a/docs/observability/current-observability-audit.md b/docs/observability/current-observability-audit.md new file mode 100644 index 0000000..3ad1439 --- /dev/null +++ b/docs/observability/current-observability-audit.md @@ -0,0 +1,31 @@ +# Current Observability Audit + +Date: 2026-08-07 + +This audit separates ephemeral operational diagnostics from durable security audit records. Frontend controls are not security controls, and observability does not authorize an action. + +| Area | Before Phase 10 | Missing visibility / privacy risk | Phase 10 disposition | Signal class | +| --- | --- | --- | --- | --- | +| Python logging | Basic text logger and Phase 8 key/value security messages | No common schema or central nested redaction | Central structured formatter, stable events, recursive redaction | Logs | +| FastAPI requests | Timing and request ID, raw paths | Unnormalized routes and inconsistent error categories | Route-template request count/duration/status plus bounded context | Logs, metrics | +| Exceptions | Framework handling plus request failure log | Unsafe client detail was possible outside known HTTP errors | Generic `500` with request reference; server-side categorized event | Logs, metrics | +| Security events | Privacy-aware but text-oriented and ephemeral | Hard to aggregate; not durable | Routed through structured logging and bounded counters | Logs, metrics; not audit ledger | +| SQLAlchemy | No SQL echo, but few stable failure signals | Session/readiness failures difficult to aggregate | Rollback plus `database.operation_failed` and duration/outcome metrics | Logs, metrics, health | +| Health | Process live and database ready checks | Policy and exposure were not canonical | Separate minimal liveness/readiness contracts | Health | +| Metrics | None | No vendor-neutral aggregate operational signal | In-process bounded registry and protected Prometheus text export | Metrics | +| Authentication | Phase 8 security failures | No success/attempt aggregate | Attempt/success/failure/throttle counters and stable events | Logs, metrics | +| Authorization | Selected Phase 8 events | Inconsistent platform/org denial aggregation | Platform/org reason categories without target data | Logs, metrics | +| Rate limiting | Process-local enforcement and security event | No aggregate limiter-category count | Bounded trigger counter | Logs, metrics | +| Uploads | Hardened validation and rejection events | Attempts/success/duration absent | Category/outcome metrics and rejection diagnostics, no filenames/bytes | Logs, metrics | +| Course Studio | User messages and domain state | Draft, conflict, preview, review, publish outcomes not aggregated | High-level events/counters; content graphs remain excluded | Logs, metrics | +| Angular | Expected status messages and lazy-chunk recovery | Raw errors could reach console; no request-reference helper | Metadata-only diagnostic service, interceptor, global handler, safe references | Browser diagnostics | +| Background work | No executing worker/queue; generation-run rows are metadata | No worker lifecycle to instrument | Fact documented; future workers must adopt the event contract | Logs/metrics when introduced | +| CI/deployment | Tests/builds and container health checks | No external collector, alert routing, or production topology | Vendor-neutral process output/endpoints and operator guidance only | Diagnostics | + +## Dependency and deployment findings + +The implementation uses Python and Angular platform facilities already present. No monitoring SDK, commercial agent, job framework, or tracing backend was added. Production documentation currently assumes process stdout, a required SQL database, and potentially multiple API processes in the future; the metrics and rate-limit stores remain per-process. Trusted-proxy and hosting topology are deliberately not invented here. + +## Durable audit boundary + +Operational logs are potentially ephemeral, mutable through retention systems, and optimized for diagnosis. `implement-platform-audit-events` remains responsible for append-oriented persistence, retention, restricted search/export, before/after security state, tamper resistance, and administrative review. diff --git a/docs/observability/database-observability.md b/docs/observability/database-observability.md new file mode 100644 index 0000000..80e5b88 --- /dev/null +++ b/docs/observability/database-observability.md @@ -0,0 +1,7 @@ +# Database Observability + +Database session providers catch `SQLAlchemyError`, roll back the active transaction, increment a bounded operation/failure counter, and emit `database.operation_failed`. Readiness records success/failure and elapsed time. Unexpected database exceptions still reach the request-level safe exception boundary with the same request ID. + +The policy deliberately excludes SQL statements, bound parameters, connection URLs, database names, credentials, and model serialization. SQLAlchemy echo remains disabled. Current coverage identifies session, authorization-session, authentication-session, and readiness boundaries; it does not provide per-query tracing, pool-exhaustion gauges, deadlock classification, or migration-state inspection because the current stack does not expose those safely through an existing instrumentation layer. + +Operators should correlate `database.operation_failed` with readiness `503` responses and request references. A future production topology may add driver/pool metrics through a vendor-neutral adapter, but must retain the no-values/no-personal-data contract. diff --git a/docs/observability/frontend-error-observability.md b/docs/observability/frontend-error-observability.md new file mode 100644 index 0000000..265073f --- /dev/null +++ b/docs/observability/frontend-error-observability.md @@ -0,0 +1,7 @@ +# Frontend Error Observability + +Angular uses `DiagnosticService` as the privacy boundary for local diagnostics. It emits a stable event and sanitized operation plus numeric status and validated backend request ID. It never includes the raw error, URL/query, response body, headers, authorization state, forms, course graphs, learner data, filenames, or stack traces. + +The HTTP diagnostic interceptor records unexpected transport/server failures. Existing authentication, permission, concealed-resource, validation, conflict, and rate-limit messages remain authoritative and accessible. A global `ErrorHandler` captures otherwise unhandled Angular failures. Lazy-chunk failures are classified before redirecting to the existing recovery page. A `WeakSet` prevents the same error object from being logged twice. + +Unexpected `5xx` responses can append `Reference: ` to appropriate messages. Routine `401`, `403`, `404`, `409`, `422`, and `429` responses do not receive reference clutter. Course Studio preserves local work after autosave failure and shows the reference when available. No diagnostics are transmitted to a commercial vendor or new backend collection endpoint. diff --git a/docs/observability/health-readiness-policy.md b/docs/observability/health-readiness-policy.md new file mode 100644 index 0000000..d542287 --- /dev/null +++ b/docs/observability/health-readiness-policy.md @@ -0,0 +1,10 @@ +# Health, Liveness, and Readiness Policy + +| Endpoint | Meaning | Dependency behavior | Exposure | +| --- | --- | --- | --- | +| `GET /health/live` | The API process can answer HTTP | Does not query the database | Minimal public machine-readable `{"status":"live"}` | +| `GET /health/ready` | The instance can safely serve database-backed traffic | Executes bounded `SELECT 1`; returns `503` on SQLAlchemy failure | Minimal dependency category/status; no host, database name, version, SQL, credentials, or exception | + +Liveness must remain healthy during a temporary database outage so an orchestrator does not confuse dependency failure with a dead process. Readiness may remove an unhealthy instance from service. The query is constant-cost, does not scan application tables, and is bounded by `READINESS_TIMEOUT_SECONDS` (default 2 seconds). No hosting-specific health topology is assumed. + +Required storage is local filesystem-backed for existing uploads and has no separately configured remote dependency to probe. No worker service exists. If either becomes mandatory infrastructure, readiness must add a bounded, non-disclosing check before claiming coverage. diff --git a/docs/observability/log-redaction-policy.md b/docs/observability/log-redaction-policy.md new file mode 100644 index 0000000..1b99092 --- /dev/null +++ b/docs/observability/log-redaction-policy.md @@ -0,0 +1,7 @@ +# Log Redaction Policy + +Redaction is defense in depth. Call sites must omit sensitive data, and the shared formatter recursively protects nested mappings and collections. Keys containing authorization, cookie, password/passwd, secret, token, API key, or credential markers become `[REDACTED]`. Bearer/JWT-like strings are replaced, binary values become a byte-count marker, strings are bounded, and unknown objects are converted to bounded text. + +Protected material includes authorization headers, cookies, JWTs, passwords and hashes, invitation/reset/verification tokens, API keys, raw query secrets, request bodies, uploaded binaries, caller filenames, assessment responses, learner records, and course/import content. Exception objects are not serialized into event fields. Server stack traces may be emitted only through controlled server logging and must never be returned to clients. + +Regression tests cover nested secrets, token-like strings, binary values, safe JSON formatting, client error-body exclusion, and forbidden metric labels. New events must use stable categorical reason codes instead of free-form exception text. diff --git a/docs/observability/logging-policy.md b/docs/observability/logging-policy.md new file mode 100644 index 0000000..2fcc116 --- /dev/null +++ b/docs/observability/logging-policy.md @@ -0,0 +1,13 @@ +# Structured Logging Policy + +Backend application events use the `echoed` logger and `emit_event()` from `backend/app/observability.py`. Production can select JSON; development can use readable key/value output. Stable event names are the aggregation contract. + +Common fields are timestamp, severity, event, message, service, component, environment, request ID, correlation ID, normalized route, method, status, duration, actor classification/role where appropriate, resource type, operation, and result. Actor or resource identifiers may be present only in restricted logs when operationally necessary; they are never metric labels. + +Canonical categories include `request.completed`, `request.slow`, `request.validation_failed`, `request.unhandled_exception`, `database.operation_failed`, `auth.login.succeeded`, `auth.login.failed`, `authorization.denied`, `rate_limit.triggered`, `upload.rejected`, and `course_studio..`. + +Never log credentials, authorization/cookie headers, JWTs, reset/invitation secrets, raw bodies, uploaded bytes or caller filenames, course graphs, lesson or assessment content, or unnecessary personal/student data. Do not enable SQL echo or bound-parameter logging in production. The formatter recursively redacts sensitive keys and token-like strings, and truncates unexpected values. Domain code should still omit sensitive data at the source. + +One layer owns each event: middleware owns HTTP completion/unhandled failures, dependencies own authorization denials, route boundaries own domain outcomes, and database session boundaries own transaction failure. Operational logs are not durable audit evidence. + +Environment controls and startup validation are canonical in [configuration.md](configuration.md). diff --git a/docs/observability/metrics-catalog.md b/docs/observability/metrics-catalog.md new file mode 100644 index 0000000..e5afb30 --- /dev/null +++ b/docs/observability/metrics-catalog.md @@ -0,0 +1,24 @@ +# Metrics Catalog + +Metrics are vendor-neutral Prometheus text generated by a locked in-process registry. Labels are bounded categories only; user, request, organization, course, email, username, and filename labels are rejected by the registry. + +| Metric | Type | Labels | Purpose | +| --- | --- | --- | --- | +| `echoed_http_requests_total` | Counter | method, normalized route, status family | Request volume/outcome | +| `echoed_http_request_duration_ms_count/sum` | Histogram summary pair | method, normalized route | Request latency | +| `echoed_http_active_requests` | Gauge | none | In-flight work | +| `echoed_request_failures_total` | Counter | category | Validation/unhandled failures | +| `echoed_request_denials_total` | Counter | category, normalized route | HTTP authorization visibility | +| `echoed_authentication_total` | Counter | operation, result | Login/registration outcomes | +| `echoed_authorization_denials_total` | Counter | scope, reason | Platform/org denials | +| `echoed_rate_limit_triggers_total` | Counter | limiter group | Throttling | +| `echoed_uploads_total` | Counter | category, result | Upload outcomes | +| `echoed_upload_duration_ms_count/sum` | Histogram summary pair | category | Upload handling latency | +| `echoed_course_studio_operations_total` | Counter | operation, result | High-level authoring outcomes | +| `echoed_database_operations_total` | Counter | operation, result | Session/readiness failures and health | +| `echoed_database_operation_duration_ms_count/sum` | Histogram summary pair | operation | Readiness/database boundary latency | +| `echoed_security_events_total` | Counter | action, result | Aggregate Phase 8 security-event flow | + +## Export security + +Collection can remain enabled while export is disabled. `/internal/metrics` returns `404` unless `METRICS_ENDPOINT_ENABLED=true`. Enabling it requires `METRICS_ACCESS_TOKEN`; callers supply `X-Metrics-Token`, compared in constant time. Invalid tokens return `403`. Operators should restrict this endpoint at their deployment boundary as defense in depth. The token is never logged. Metrics are per-process and reset at process restart; multi-process aggregation belongs to the operator’s scraper, not this repository. diff --git a/docs/observability/observability-performance.md b/docs/observability/observability-performance.md new file mode 100644 index 0000000..6045cd7 --- /dev/null +++ b/docs/observability/observability-performance.md @@ -0,0 +1,21 @@ +# Observability Performance + +Date: 2026-08-07 + +## Method + +Local measurements use the in-process FastAPI test client after warm-up. Five batches of 200 liveness requests compared metrics enabled with metrics disabled while request-completion output was suppressed in both cases. Seven batches of 2,000 Course Studio operational-event calls measured the bounded event/counter boundary with the logger disabled, isolating metric overhead. Two small batches of two missing-account login attempts measured the deliberately expensive constant-work authentication failure path; a larger initial sample exceeded the 120-second local command budget and was discarded. The existing Course Studio representative large-graph backend test remains the autosave regression guard. + +## Results + +| Scenario | Instrumentation enabled | Comparison | Observation | +| --- | ---: | ---: | --- | +| `GET /health/live`, median batch time per request | 3.525 ms | 5.092 ms with metrics disabled | Difference is within local TestClient/host noise; no measured regression | +| Course Studio event/counter, median per call | 0.03169 ms | 0.02526 ms with metrics disabled | Approximately 0.00643 ms incremental local cost | +| Missing-account login, median per request | 4102.850 ms | 4059.793 ms with metrics disabled | 43.057 ms / about 1.06%; sample is too small and password verification dominates | + +Structured completion-log sink cost was intentionally excluded because it depends on the deployment’s stdout collector. Database readiness remains covered by full-suite functional tests, but a stable local A/B latency result was not claimed for it. No production-scale latency or throughput claim is made. + +The instrumentation is constant work per request: context-variable set/reset, monotonic timing, lock-protected bounded counter updates, and one completion event when request logging is enabled. It does not serialize bodies or course graphs. Prometheus rendering takes the registry lock only when the protected endpoint is scraped. + +These measurements are regression signals, not production-scale guarantees. SQLite, TestClient, developer logging, host load, and the process-local registry differ from production PostgreSQL and deployment collectors. Multi-process scrape aggregation and sustained concurrency require deployment-specific validation. diff --git a/docs/observability/phase-10-baseline.md b/docs/observability/phase-10-baseline.md new file mode 100644 index 0000000..fba2212 --- /dev/null +++ b/docs/observability/phase-10-baseline.md @@ -0,0 +1,40 @@ +# Phase 10 Observability Baseline + +Date: 2026-08-07 + +This baseline records the repository before Phase 10 runtime changes. It is an implementation audit, not a production-readiness or monitoring-coverage claim. + +## Repository and OpenSpec state + +- Branch: `aqw-echoed-dev` +- Commit: `c6336fa79458c08da75746954615a86522e766a8` +- Dirty tree: extensive expected, uncommitted Phase 8 security-hardening and unified Course Studio work is present. Phase 10 preserves all of it. Other older active OpenSpec changes also exist; no archived history is modified. +- `harden-platform-security`: complete, 30/30 tasks, strictly valid, active and not archived. +- `establish-platform-maturity-foundation`: complete, 21/21 tasks, active and not archived. +- `unify-course-authoring-experience`: complete, 58/58 tasks, active and not archived. +- Most-recent verified baselines: 269 backend tests, 299 Angular tests, 22 Playwright tests, passing production Angular build, zero production npm vulnerabilities, strict Phase 8 validation, and clean `git diff --check` apart from line-ending notices. + +## Existing observability architecture + +- Backend logging: `backend/app/log.py` uses Python `logging.basicConfig` with environment-selected level and human-readable timestamp/name/level/message output. Event data is embedded in format strings rather than emitted as structured records. Invalid log levels silently become `INFO`. +- HTTP middleware: `backend/app/main.py` times every request, accepts a 1–128 character safe `X-Request-ID` or generates a UUID, stores it on `request.state`, returns it as `X-Request-ID`, writes completion/failure logs, and adds baseline security headers. It logs raw URL paths rather than normalized route templates. +- Correlation: request ID is the only correlation value. It is available to handlers and Phase 8 security events but is not distinct from a caller correlation hint or distributed trace ID. No internal HTTP service client or request-spawned background worker currently needs propagation. +- Security events: Phase 8 `security_event` writes privacy-aware key/value messages for authentication denial, rate limiting, role/user changes, final-admin protection, invitations, upload rejection, and forum moderation. These are ephemeral operational logs, not durable audit records. +- Error handling: middleware logs unexpected exceptions and lets framework handling produce the response. Known HTTP/validation errors are not categorized centrally; request IDs are not consistently included in safe error bodies; database/dependency failures have limited stable event names. +- Health: `/health/live` returns process liveness. `/health/ready` executes bounded `SELECT 1`, returns 503 with a generic database message on SQLAlchemy errors, and currently includes the public dependency name/status on success. +- Metrics: none. There is no counter, histogram, active-request gauge, export endpoint, or cardinality policy. +- Database: SQLAlchemy engine/session setup has no failure/rollback instrumentation; unrestricted SQL echo is not enabled. SQLite is used in tests and disposable verification; deployment documentation assumes PostgreSQL and one Uvicorn process. +- Background work: no Celery/RQ/worker/scheduler or application-executed queue exists. Generation-run rows contain product metadata but are not a worker implementation. +- Course Studio: draft/autosave/conflict/import/preview/review/publish/restore workflows have domain behavior and tests but no unified operational events or metrics. Frontend failures use local lifecycle messages. +- Angular diagnostics: expected security status messages and lazy-chunk recovery exist. Several components log raw error objects to the browser console; there is no global privacy-safe error handler or shared request-reference extraction. +- Deployment assumptions: one API process, direct-peer rate limiting, no trusted forwarded headers, vendor-neutral logs written to process output, no selected metrics scraper or commercial monitoring service. +- Rate limiter: locked, process-local fixed windows configured by environment; triggers call Phase 8 security logging and return `429`/`Retry-After`, but no operational metrics exist. + +## Baseline risks and constraints + +- Raw exception objects or console logs may contain more context than policy permits. +- Raw request paths can create cardinality and privacy problems; raw queries/bodies must never be logged globally. +- Request IDs are useful but are not trace IDs and caller-provided values require stricter conceptual separation. +- Public metrics would reveal internal traffic/security posture; any export must be disabled by default or explicitly protected. +- Database errors, Course Studio lifecycle failures, and authorization denials cannot yet be reliably aggregated by stable event name. +- Drive C began this phase critically low on free space. Only recoverable unused Playwright browser caches were removed; source, databases, uploads, and user work were untouched. Verification must continue to clean generated artifacts and report environmental limitations accurately. diff --git a/docs/observability/phase-10-verification.md b/docs/observability/phase-10-verification.md new file mode 100644 index 0000000..9a76b27 --- /dev/null +++ b/docs/observability/phase-10-verification.md @@ -0,0 +1,59 @@ +# Phase 10 Platform Observability Verification + +Date: 2026-08-07 + +## Scope and state + +OpenSpec change `establish-platform-observability` is active (not archived) and strictly valid. Phase 8 `harden-platform-security` is complete, strictly valid, and active/not archived. `establish-platform-maturity-foundation` and `unify-course-authoring-experience` are also complete and active/not archived. Archived OpenSpec history was not modified. + +The implementation adds no dependency and no database migration. It preserves the dirty, user-authored Phase 8 and Course Studio work recorded in [phase-10-baseline.md](phase-10-baseline.md). + +## Implemented evidence + +- Central environment-validated structured logging supports JSON/developer output, stable event names, request/correlation context, privacy-aware fields, token/bearer/nested-key/binary redaction, and server-only message-free stack frame metadata. +- Every request receives a bounded accepted-or-generated request ID; invalid IDs are replaced, a separate bounded correlation hint is supported, and response/error headers/bodies expose safe references. +- HTTP request counts, active requests, normalized-route latency, status families, denial/validation categories, safe unhandled exceptions, and slow requests are instrumented. +- `/health/live` is process-only. `/health/ready` performs a timeout-bounded constant database query and returns a non-disclosing `503` on failure. +- A locked process-local metrics registry rejects personal/high-cardinality labels. `/internal/metrics` is concealed by default and requires a configured token with constant-time comparison when enabled. +- Authentication, authorization, Phase 8 security events, rate limiting, uploads, database boundaries, and supported Course Studio lifecycle boundaries emit bounded metrics and structured diagnostics. +- Angular diagnostics serialize only event/operation/status/request ID, cover unexpected HTTP/global/lazy-chunk failures, add references only to unexpected server errors, and preserve Course Studio work after save/publish failure. +- No executing worker/queue exists; this is documented rather than simulated. +- Operational and incident guides document correlation, safe evidence, containment, escalation, and the durable-audit boundary. + +## Verification results + +| Check | Result | +| --- | --- | +| Backend full suite | **PASS — 278 passed**, 4,169 existing deprecation warnings, 126.86 s | +| Angular full suite | **PASS — 308 passed** using repository `ChromeHeadlessNoSandbox` with persistent cache disabled | +| Playwright full suite | **PASS — 23 passed**, 29.9 s | +| Angular production build | **PASS** — 439.76 kB raw / 120.41 kB estimated-transfer initial bundle | +| Production npm audit | **PASS — 0 vulnerabilities** | +| Backend syntax check | **PASS** — `python -m compileall -q backend/app backend/tests` | +| Backend/frontend lint/format | No configured runnable lint/format scripts or installed backend linter were found; compilation, full tests, build, OpenSpec, and diff checks are the repository-supported gates available in this tree | +| Strict OpenSpec validation | **PASS** — `openspec validate establish-platform-observability --strict` | +| `git diff --check` | **PASS**; only existing line-ending conversion notices were printed | +| Cleanup | **PASS** — temporary ports 4200/8000 stopped; Phase 10 database, logs, results, Angular cache, and pytest temp/cache removed | + +The first backend full run reached 277/278 when the nearly full drive prevented multipart spooling and produced an environmental `400`; after removing only generated caches, the isolated test and final 278-test suite passed. The first cache-disabled Angular attempt used a generic launcher that disconnected; the repository-configured launcher passed 308/308. The first Playwright run passed 21/23 with two pre-existing sequencing timeouts; navigation now waits for DOM readiness, and the Course Studio mock now represents the post-create draft reload before moving tabs. The final complete run passed 23/23 without sleeps. + +## Performance evidence + +Local TestClient measurements found 3.525 ms/request for instrumented liveness versus 5.092 ms with metrics disabled (host noise dominated), 0.03169 ms per instrumented Course Studio event versus 0.02526 ms without metrics (about 0.00643 ms incremental), and 4102.850 ms versus 4059.793 ms for the expensive missing-account authentication path (about 1.06%, too small a sample for a production claim). See [observability-performance.md](observability-performance.md). + +## Security regression evidence + +The 278 backend and 23 browser scenarios retain anonymous forum denial, explicit role/scope controls, final-super-admin and self-lockout safeguards, rate limiting, upload validation, minimized administrative schemas, and organization isolation. Redaction tests exclude secrets/content from logs and browser diagnostics; metric cardinality tests reject personal identifiers; health and metrics tests verify non-disclosure and access policy. + +## Known limitations and deferred work + +- Logs, metrics, security events, and rate limits are process-local; metrics reset on restart and need operator aggregation in a multi-process deployment. +- Request/correlation IDs are not distributed traces. No collector, dashboards, alert routing, SLO/error-budget program, or commercial vendor is selected. +- Operational logs are not append-only, tamper resistant, retention governed, or a durable audit ledger. +- Database coverage is boundary-level, not unrestricted SQL/per-query tracing, pool/deadlock detail, or migration-state monitoring. +- No worker architecture exists. Future workers must adopt lifecycle/correlation instrumentation. +- Trusted proxy/host and full production configuration validation remain part of operational readiness. +- Some public assets and legacy organization-ownership limitations remain as recorded by Phase 8. +- The drive remained critically constrained; verification required cache-disabled Angular commands. This is an environment limitation, not a production capacity result. + +The next recommended OpenSpec change is `establish-operational-readiness`. The later `implement-platform-audit-events` change remains responsible for durable append-only actor/action/target/organization records, atomic before/after security state where appropriate, retention, restricted search/export, privacy, administrative review, and tamper resistance. diff --git a/docs/observability/request-correlation.md b/docs/observability/request-correlation.md new file mode 100644 index 0000000..6d3b8ae --- /dev/null +++ b/docs/observability/request-correlation.md @@ -0,0 +1,7 @@ +# Request Correlation + +Every response carries `X-Request-ID`. The API accepts an existing request ID only when it is 1–128 characters from the safe identifier alphabet; otherwise it generates a UUID. This preserves safe upstream correlation without trusting arbitrary caller text. + +`X-Correlation-ID` is separate and configurable with `CORRELATION_HEADER`. A supplied value is accepted only when bounded to 64 safe characters. It is a diagnostic hint, not authentication, authorization, or a distributed trace ID. Both values are stored on request state and context variables, included in structured events, returned in response headers when present, and reset after the request. + +The repository has no internal HTTP client or executing request-spawned worker today. A future client or worker should explicitly copy correlation context, generate its own operation/request ID, and avoid placing IDs in metric labels. Support staff may ask users for a displayed request reference; they must never ask for tokens, cookies, passwords, or course/learner content. diff --git a/docs/operations/alerting-and-escalation.md b/docs/operations/alerting-and-escalation.md new file mode 100644 index 0000000..7234c12 --- /dev/null +++ b/docs/operations/alerting-and-escalation.md @@ -0,0 +1,17 @@ +# Alerting, Ownership, and Escalation + +The application exposes signals; no external notification provider or on-call routing is wired. The deploying organization must assign the named owner roles and connect collection before claiming alert coverage. + +| Condition | Severity / trigger | Owner and response | Escalation / runbook | +| --- | --- | --- | --- | +| Liveness unavailable | Critical; 2 consecutive checks | Platform operator: stop promotion/restart once, inspect lifecycle/config logs | Engineering incident lead; deployment runbook | +| Readiness/database unavailable | Critical; 2 minutes | Database/operator owner: remove traffic, verify dependency and recent migration | Incident lead + database owner; Phase 10 incident guide | +| 5xx rate | High; >2% for 5 minutes or any sharp release-correlated rise | Application operator: correlate request IDs, halt/rollback release if safe | Backend owner; observability runbook | +| p95 latency | Medium; >1 second for 15 minutes | Backend owner: inspect slow routes/database signals | Incident lead if user impact grows | +| Login failures | Medium; 3x comparable baseline for 10 minutes | Security responder: distinguish attack, outage, or release regression | Security owner; incident guide | +| Rate-limit triggers | Medium; sustained unexpected triggers for 10 minutes | Security/application owner: inspect limiter category/process count | Security owner; never disable blindly | +| Backup missed/failed | Critical; no verified daily backup by next window | Data owner: stop risky deploys and repair backup path | Incident lead; backup/restore runbook | +| Restore drill failed | Critical readiness blocker | Data owner: preserve evidence, correct before production claim | Engineering lead | +| Disk/storage write rejection | High; repeated upload failures or capacity alert | Storage owner: contain uploads, inspect persistence/capacity | Incident lead; storage section | + +External aggregation, paging, schedules, contact lists, maintenance-window suppression, and escalation timers remain infrastructure/organizational dependencies. diff --git a/docs/operations/backup-and-restore.md b/docs/operations/backup-and-restore.md new file mode 100644 index 0000000..bd659b6 --- /dev/null +++ b/docs/operations/backup-and-restore.md @@ -0,0 +1,23 @@ +# Backup, Restore, and Recovery + +## Production policy + +- PostgreSQL: daily encrypted logical or provider-native backup, plus provider-supported continuous recovery if selected. Retain 7 daily and 4 weekly recovery points; store in a separate failure domain/account with access logs and least privilege. +- Uploads: daily versioned backup of `STORYBOOK_PATH`, `COLORINGS_PATH`, and `BADGES_PATH`, coordinated closely enough with the database to preserve ownership references. Apply the same 7-daily/4-weekly retention. +- Configuration: version non-secret templates and immutable release metadata. Back up secrets only within the approved secret manager; never in repository bundles. +- Angular/static application assets: rebuild from the immutable release; they are not mutable backup state. +- Verify every artifact checksum/catalog after creation and run an isolated restore at least quarterly and after storage/schema changes. + +Initial conditional targets are RPO 24 hours and RTO 4 hours. They depend on successfully scheduled daily off-host backups, available credentials/operators, compatible release artifacts, and a restore procedure measured with representative production volume. Local drill timings prove tooling behavior only and do not prove these production targets. + +## Safe repository drill + +`python -m scripts.operational_backup` supports only explicitly acknowledged development/test SQLite and filesystem fixtures. It refuses staging/production, existing restore targets, traversal manifests, and checksum mismatches. Example from `backend`: + +```text +python -m scripts.operational_backup backup --database test.db --storage badges=badges --output .pytest_tmp/backup --acknowledge-test-data +python -m scripts.operational_backup verify --bundle .pytest_tmp/backup +python -m scripts.operational_backup restore --bundle .pytest_tmp/backup --database-target .pytest_tmp/restored.db --storage-target .pytest_tmp/restored-uploads --acknowledge-test-data +``` + +For PostgreSQL, use the selected provider's consistent snapshot or `pg_dump`/`pg_restore` with credentials supplied out of band. Restore into isolation, verify schema heads, database consistency, upload checksums/ownership, readiness, and representative role workflows before cutover. Never overwrite a live database as a rehearsal. diff --git a/docs/operations/deployment-runbook.md b/docs/operations/deployment-runbook.md new file mode 100644 index 0000000..165fa9e --- /dev/null +++ b/docs/operations/deployment-runbook.md @@ -0,0 +1,18 @@ +# Deployment Runbook + +## Release gates + +1. Identify the immutable commit/image digest and retain the prior known-good artifact and reviewed non-secret configuration. +2. Review schema compatibility and migration files. For destructive/irreversible work, stop until a tested backup/restore and explicit approval exist. +3. Verify encrypted off-host database and upload backups and record integrity evidence. +4. Supply the target environment configuration and run `python -m scripts.validate_operational_config` from `backend`. +5. Check database/storage reachability and capacity using provider tooling; never print a connection URL. +6. Run `bash migrate.sh` exactly once as a controlled release job. It validates, runs `alembic upgrade heads`, and verifies current heads. Application instances do not migrate. +7. Deploy the immutable artifact. `start.sh` validates again and launches Uvicorn with implicit proxy headers disabled and bounded graceful shutdown. +8. Poll `/health/live` for process startup, then `/health/ready` for database serviceability. Run `python -m scripts.verify_deployment --base-url ` from an authorized network path. +9. Exercise safe public/authenticated smoke paths appropriate to the release without mutating production data unnecessarily. +10. Observe request/error/latency/readiness/auth/rate-limit signals for at least 15 minutes (longer for high-risk schema changes). Close the release only when alerts remain below policy thresholds. + +Any failed gate stops promotion. Readiness failure removes/keeps the instance out of service; liveness failure permits restart. Do not restart-loop a configuration or migration failure. + +Docker Compose is a development reference. It now runs a one-shot migration service before backend startup, waits for PostgreSQL health, and uses readiness plus a 35-second stop grace period. Its default credentials and bind mounts are not a production manifest. The container workflow publishes a commit-SHA tag for rollback provenance and also updates `latest`; deploy by SHA/digest, never by mutable tag alone. diff --git a/docs/operations/health-and-shutdown.md b/docs/operations/health-and-shutdown.md new file mode 100644 index 0000000..1ce3d10 --- /dev/null +++ b/docs/operations/health-and-shutdown.md @@ -0,0 +1,11 @@ +# Health and Graceful Shutdown + +| Endpoint/signal | Use | Behavior | +| --- | --- | --- | +| `/health/live` | Process liveness/restart | Returns only `{"status":"live"}` and never probes PostgreSQL. | +| `/health/ready` | Traffic admission, deployment verification, dependency outage | Runs bounded `SELECT 1`; returns 503 and generic database unavailable state on failure. | +| `/internal/metrics` | Authorized monitoring | Disabled by default; token-protected when enabled. Never use as a public health check. | + +Neither health response exposes hosts, database names, credentials, SQL, versions, or topology. During termination, Uvicorn stops accepting work and waits up to `GRACEFUL_SHUTDOWN_SECONDS`; the FastAPI lifespan emits bounded lifecycle events and disposes the SQLAlchemy engine. The orchestrator stop grace must exceed the Uvicorn bound (Compose uses 35 vs 30 seconds). If the process exceeds the bound, termination is forced and the event is an incident signal. + +There is no worker/job framework to drain. Any future worker must define lease, retry, idempotency, and shutdown semantics separately. diff --git a/docs/operations/incident-observability-guide.md b/docs/operations/incident-observability-guide.md new file mode 100644 index 0000000..644093a --- /dev/null +++ b/docs/operations/incident-observability-guide.md @@ -0,0 +1,19 @@ +# Incident Observability Guide + +This is focused incident-readiness guidance, not a complete incident-response program. Preserve request IDs, stable events, aggregate metrics, UTC time windows, deployed commit/config version, and operator actions. Never preserve secrets or unnecessary learner/course content. + +| Scenario | Detection and evidence | Immediate containment | Escalate when | +| --- | --- | --- | --- | +| Elevated login failures | `echoed_authentication_total` failure ratio, `auth.login.failed`, limiter triggers | Confirm service/readiness; check broad category/config changes without account enumeration | Sustained increase, suspected credential attack, or legitimate users broadly blocked | +| Elevated 500s | HTTP status-family/request-failure metrics and `request.unhandled_exception` by normalized route/request ID | Remove unready instances; pause a failing high-impact action if normal controls permit | Error ratio persists, data integrity may be affected, or multiple roles/workflows fail | +| Database unavailable | Readiness `503`, database failure/outcome/duration | Stop routing to unready instances; verify database service through approved tools; avoid retry storms | Outage exceeds operational objective, recovery risks data, or all instances are unready | +| Unexpected throttling | Limiter-group triggers and `rate_limit.triggered` | Verify environment limits and per-process topology; do not disable auth protection casually | Legitimate flow is blocked broadly or abuse bypass/distributed inconsistency is suspected | +| Cross-org denials | Authorization denial scope/reason aggregates and correlated request IDs | Confirm tenant boundary remains closed; preserve actor/target metadata only from authorized logs | Pattern suggests probing, a permitted request is denied, or any cross-org data exposure is suspected | +| Upload failures | Upload result/duration and rejection codes | Keep validation enabled; verify size/type/store availability | Valid uploads broadly fail or content execution/unauthorized access is suspected | +| Studio save conflicts | Course Studio conflict outcomes and request references | Preserve local work; use documented reload/recovered-copy path; avoid overwriting server state | Conflicts become systemic or published-version isolation is questioned | +| Publishing failures | Publish attempt/blocked/success and HTTP failures | Keep current learner availability unchanged; resolve validation/dependency cause | State appears partially published or learners see unapproved content | +| Worker failures | Not applicable: no executing worker exists | Do not infer queue health from generation-run metadata | A worker is introduced without lifecycle instrumentation | + +For suspected data exposure or privilege compromise, prioritize containment and preservation over diagnostic verbosity. Use the security escalation process; operational logs are not a tamper-resistant audit ledger. + +Phase 11 release containment, application/configuration/database rollback boundaries, recovery ownership, and alert thresholds are defined in the [deployment runbook](deployment-runbook.md), [migration and rollback policy](migration-and-rollback-policy.md), [alerting policy](alerting-and-escalation.md), and [backup/restore procedure](backup-and-restore.md). Do not attempt an application-only rollback when schema compatibility is unknown. diff --git a/docs/operations/migration-and-rollback-policy.md b/docs/operations/migration-and-rollback-policy.md new file mode 100644 index 0000000..64e6d4c --- /dev/null +++ b/docs/operations/migration-and-rollback-policy.md @@ -0,0 +1,13 @@ +# Migration and Rollback Policy + +Migrations execute once, before application rollout, through `backend/migrate.sh`; normal startup never changes schema. Failure, timeout, or head mismatch stops rollout. PostgreSQL backup completion is mandatory before a risky migration. Verify migration heads, readiness, and a safe read/write smoke afterward. + +Migration authors must state whether the change is expand/contract compatible, locking/volume risk, and reversibility. Alembic `downgrade` presence is not proof of safe reversal. Data loss, column/type contraction, and backfills require a tested restore or forward-repair plan. + +Rollback decision: + +- Application rollback: redeploy the prior immutable SHA/digest only when it is compatible with the current schema and configuration. +- Configuration rollback: restore the prior reviewed non-secret configuration and appropriate credential version, revalidate, redeploy, and verify health. Never paste old secrets into evidence. +- Database rollback: use an explicitly tested downgrade only if declared safe; otherwise restore the pre-migration database and matching uploads to an isolated target, verify, then perform a controlled cutover. + +Application-only rollback is unsafe after a backward-incompatible schema change. If compatibility is unknown, contain traffic and escalate rather than guessing. Database restore can discard post-backup writes and therefore requires an approved recovery point and stakeholder decision. diff --git a/docs/operations/observability-runbook.md b/docs/operations/observability-runbook.md new file mode 100644 index 0000000..5602d35 --- /dev/null +++ b/docs/operations/observability-runbook.md @@ -0,0 +1,28 @@ +# Observability Runbook + +This vendor-neutral runbook uses process logs, health endpoints, and the optional protected metrics endpoint. Adapt collection commands to the approved deployment without exposing credentials. + +## Routine checks + +1. Call `GET /health/live`; expect `200` and `status=alive`. +2. Call `GET /health/ready`; expect `200` and the database category marked available. A `503` means the instance should not receive database-backed traffic. +3. If metrics export is enabled, access `GET /internal/metrics` through the operator boundary with `X-Metrics-Token`. Never paste that token into a ticket or command transcript. +4. Inspect process stdout or the configured log collector. Filter by stable `event`, then by request ID. Do not search by passwords, tokens, learner content, or full request bodies. + +## Correlating user-visible failures + +Ask the user for the displayed reference ID, approximate time, action, and non-sensitive page name. Find the matching `request_id`, inspect `request.unhandled_exception` or domain events, then compare normalized-route status/duration metrics. Do not request authorization headers, cookies, passwords, invitation/reset links, uploaded files, assessment answers, lesson content, or screenshots containing student data. + +## Common diagnosis + +- Authorization: inspect `authorization.denied`, `echoed_authorization_denials_total`, and `echoed_request_denials_total`; confirm role/org category without probing protected target data. +- Rate limiting: inspect `rate_limit.triggered` and trigger counts by limiter group; verify environment limits and process-local deployment behavior. +- Database: inspect `database.operation_failed`, readiness, and database outcome/duration metrics. Logs intentionally omit SQL and connection details. +- Course Studio: correlate draft save/conflict/preview/review/publish events with the request reference. Confirm local work remained dirty after autosave failure before asking the author to retry. +- Uploads: inspect upload category/outcome/duration and rejection reason code. Never collect the binary or caller filename solely for diagnostics. + +## Changing verbosity safely + +Change `LOG_LEVEL` to a supported level and, for production collection, prefer `LOG_FORMAT=json`. Restart/redeploy through the normal operator process because settings load at startup. Never enable SQL echo, raw-body logging, or secret-bearing headers. Record the time and reason, reproduce only as long as necessary, then restore the prior level and restart. Confirm health/readiness and that logs remain redacted. + +Release, migration, shutdown, backup/restore, and recovery decisions are governed by the [Phase 11 deployment runbook](deployment-runbook.md) and [backup/restore procedure](backup-and-restore.md). Observability indicates state; it does not replace those operational gates. diff --git a/docs/operations/operational-drills.md b/docs/operations/operational-drills.md new file mode 100644 index 0000000..5cf0dc2 --- /dev/null +++ b/docs/operations/operational-drills.md @@ -0,0 +1,13 @@ +# Operational Drills + +Run from `backend` in a development/test environment: + +```text +python -m scripts.run_operational_drills +``` + +The runner refuses staging/production and uses a temporary directory. Each result includes duration, observed behavior, and pass/fail. It covers unsafe production configuration rejection; migration graph/startup/explicit-gate verification; ASGI startup, liveness, database-unavailable readiness, and shutdown; failed post-deploy verification; backup/integrity/restore/rollback/storage recovery; and synthetic secret rotation. Targeted tests additionally cover trusted/untrusted proxy behavior, host rejection, readiness success, corrupted backup rejection, and engine disposal. + +Pass criteria: every supported result is true, no protected value is emitted, unavailable dependencies fail the correct gate, restored data/assets match, and temporary data is removed. A provider-specific production-equivalent rehearsal must additionally execute migrations against isolated PostgreSQL, terminate a real container/process with the platform signal, restore representative encrypted off-host backups, test traffic removal, and exercise external alert delivery. Those drills must never target uncontrolled production data. + +Observed results and limitations are recorded in [Phase 11 verification](phase-11-operational-readiness-verification.md). diff --git a/docs/operations/phase-11-baseline.md b/docs/operations/phase-11-baseline.md new file mode 100644 index 0000000..79df5f3 --- /dev/null +++ b/docs/operations/phase-11-baseline.md @@ -0,0 +1,23 @@ +# Phase 11 Operational Readiness Baseline + +Recorded 2026-08-07 before implementation. + +| Item | Starting evidence | +| --- | --- | +| Branch / commit | `aqw-echoed-dev` / `c6336fa79458c08da75746954615a86522e766a8` | +| Working tree | Dirty with extensive user-authored Phase 8, Course Studio, and Phase 10 work. It was preserved; no reset, clean, or stash was used. | +| Phase 10 | `establish-platform-observability` complete and strict-valid, active and not archived. Required dependency evidence: `docs/observability/phase-10-verification.md`. | +| Other maturity changes | `harden-platform-security`, `unify-course-authoring-experience`, and `establish-platform-maturity-foundation` complete but active/not archived. Several older unrelated changes remain active; see `openspec list`. | +| Baselines | Backend 278, Angular 308, Playwright 23 passing. | +| Logging/metrics | Structured redacted logs, request/correlation IDs, protected process-local metrics, and security/Course Studio signals from Phase 10. | +| Health | Public minimal `/health/live`; database-aware `/health/ready`; protected `/internal/metrics`. | +| Startup/migrations | `backend/start.sh` automatically ran `alembic upgrade head`, then Uvicorn. No separation or head gate. | +| Production config | JWT required, but database/origin/upload settings had development fallbacks; no centralized production validator. `.env` was loaded by database/migrations. | +| Host/proxy | No trusted-host middleware. Rate limiting used the socket peer and ignored forwarding headers, but server launch did not explicitly disable implicit proxy handling. | +| Deployment | Development Docker Compose, backend Dockerfile, CI migration/tests, and a `latest` container tag; no immutable release procedure. | +| Persistent state | PostgreSQL volume plus local `storybook`, `colorings`, and `badges` upload paths. Angular static output is rebuildable. MinIO is present in Compose but unused by application code. | +| Backup/restore | No tooling, schedule, integrity manifest, restore drill, RPO, or RTO. | +| Shutdown/workers | Uvicorn defaults; no application resource-disposal hook. No real queue, scheduler, or worker architecture. | +| CI | PostgreSQL migration, backend tests, Angular tests; separate container build workflow. | + +The baseline is observational. Canonical Phase 11 behavior is in the documents linked from [README](../../README.md). diff --git a/docs/operations/phase-11-operational-readiness-verification.md b/docs/operations/phase-11-operational-readiness-verification.md new file mode 100644 index 0000000..ea97fcc --- /dev/null +++ b/docs/operations/phase-11-operational-readiness-verification.md @@ -0,0 +1,86 @@ +# Phase 11 Operational Readiness Verification + +Date: 2026-08-07 + +## Scope and baseline + +- OpenSpec change: `establish-operational-readiness` (active, not archived). +- Starting branch/commit: `aqw-echoed-dev` / `c6336fa79458c08da75746954615a86522e766a8`. +- Starting tree: dirty with user-authored Phase 8, Course Studio, Phase 10, and unrelated active-change work. No reset, clean, checkout, or stash was used. +- Dependency: `establish-platform-observability` was complete and strict-valid but active/not archived. `harden-platform-security`, `unify-course-authoring-experience`, and `establish-platform-maturity-foundation` were also complete and active/not archived. +- Starting verified baselines: backend 278, Angular 308, Playwright 23. + +## Implemented contract + +- `app.operational_config` validates environment identity, PostgreSQL URL category, JWT policy, HTTPS origins/external URL, allowed hosts, explicit proxy peer CIDRs, release/deployment identity, storage persistence acknowledgement/paths, non-automatic migrations, structured logs, metrics, request diagnostics, and shutdown bounds. Production never loads dotenv. +- `scripts.validate_operational_config` provides a value-free validation-only gate. A synthetic valid production configuration returned success; replacing its host allowlist with `*` returned exit 2 and only `Operational configuration invalid: ALLOWED_HOSTS...`. +- Starlette trusted-host middleware rejects unexpected hosts. `network_trust` applies forwarded client/protocol/host data only when proxy trust is enabled and the direct peer matches an explicit IP/CIDR. Uvicorn starts with `--no-proxy-headers`; the rate limiter uses the resolved safe client address. +- Normal `start.sh` no longer migrates. `migrate.sh` validates, runs `alembic upgrade heads`, and verifies database heads; Docker Compose models it as a one-shot dependency and publishes health/shutdown configuration. The container workflow adds an immutable commit-SHA tag. +- FastAPI lifespan events cover application start and shutdown, mark draining state, and dispose the SQLAlchemy engine. Uvicorn and Compose have bounded, ordered shutdown settings. +- Safe development/test backup tooling creates a SQLite online backup plus uploaded-asset bundle, versioned manifest, byte counts, and SHA-256 integrity records; restore requires absent targets and rechecks database integrity. It rejects production/staging, path traversal, corruption, and unacknowledged data. +- Deployment, migrations/rollback, health/shutdown, SLOs, alerts/escalation, backup/restore, storage ownership, secret rotation, and drills have canonical operations documents. Phase 10 runbooks are cross-linked rather than duplicated. +- No dependency or database schema migration was added. + +## Operational drills + +Command: `backend/venv/Scripts/python.exe -m scripts.run_operational_drills` from `backend`. + +| Drill | Result | Observed evidence | +| --- | --- | --- | +| Invalid production configuration | Pass, 0.09 ms | Unsafe wildcard host rejected without values. | +| Migration lifecycle | Pass, 546.93 ms | Repository head found; startup non-mutating; explicit upgrade and head-verification gates present. | +| Startup, health, database outage, shutdown | Pass, 776.07 ms | ASGI lifecycle completed; liveness stayed 200 while a deterministic synthetic database outage made readiness return generic 503; shutdown hooks ran. | +| Failed post-deploy | Pass, 230.89 ms | Unavailable readiness target stopped verification. | +| Backup/restore/rollback/storage recovery | Pass, 67.88 ms | Manifest and checksums verified; isolated database record and asset bytes restored to known-good state. | +| Secret/config rotation simulation | Pass, 0.93 ms | Old and replacement synthetic production configs validated independently; neither value was emitted. | + +Targeted tests additionally verified valid/invalid production matrices, localhost/wildcard/HTTP/default rejection, trusted and spoofed forwarding metadata, allowed/rejected hosts, backup corruption/traversal/target safety, restored SQL usability, asset recovery, and database-engine disposal. + +The Alembic history contains PostgreSQL-specific DDL. A disposable SQLite upgrade correctly cannot represent production migration execution. The local PostgreSQL service and Docker daemon were unavailable, and the critically full drive could not initialize a disposable PostgreSQL cluster. Therefore this evidence verifies the migration graph and release gates, while the existing PostgreSQL CI migration job and the production-equivalent pre-deploy rehearsal remain mandatory before a real deployment. No user database was touched. + +## Recovery objectives and ownership + +- Initial RPO: 24 hours, conditional on an operator actually scheduling daily encrypted, off-host PostgreSQL and upload backups. +- Initial RTO: 4 hours, conditional on available known-good artifacts/configuration, credentials, operators, and representative restore capacity. +- Local 67.88 ms recovery only proves small-fixture tooling correctness and does not validate production volume or the 4-hour target. +- PostgreSQL and uploaded assets are mutable backup state; Angular/backend artifacts are rebuilt; non-secret configuration is reproducible; secret values remain in an external secret manager. + +## Automated verification + +| Gate | Final result | +| --- | --- | +| Backend complete suite | **PASS — 299 passed**, 4169 existing deprecation warnings, 133.80 s. Baseline increased by 21 targeted tests, including both cross-version nested-route normalization representations. | +| Backend compile/static syntax | **PASS** — `compileall` over app, scripts, and tests. | +| Backend lint/format | Not configured in repository/venv; no pass claimed. | +| Angular complete suite | **PASS — 308 passed**. Final run used one build worker after an initial local Node memory failure. No baseline reduction. | +| Frontend lint/format | No lint/format scripts configured; no pass claimed. | +| Production Angular build | **PASS** — initial bundle 439.76 kB raw / 120.41 kB estimated transfer. | +| Playwright complete suite | **PASS — 23 passed**, 28.8 s, one Chromium worker. | +| Production dependency audit | **PASS — 0 vulnerabilities** from `npm audit --omit=dev`. | +| Docker Compose syntax | **PASS** — `docker compose -f docker-compose.yml config --quiet`. | +| Strict OpenSpec | **PASS** — `openspec validate establish-operational-readiness --strict`. | +| `git diff --check` | **PASS**; line-ending notices only. | + +Phase 8/10 regressions are covered by the 299 backend, 308 Angular, and 23 Playwright suites, including forum fail-closed behavior, role/org boundaries, accessible throttling, safe references, health, metrics, redaction, uploads, and Course Studio behavior. + +## Cleanup + +Disposable API/Angular servers on ports 8000/4200 were stopped by exact listener PID. `phase11_playwright.db`, temporary logs, Playwright results/report, generated Angular `dist`, backup bundles, restored fixtures, and drill directories were removed. No real secrets or production data were used or committed. + +## Known limitations and deferred work + +- No hosting/reverse-proxy provider, external monitor/alert delivery, centralized logs/metrics, production backup scheduler/destination, or object-storage service is selected. +- SLO calculations and rate limiting remain process-local and cannot establish fleet compliance. +- Repository backup automation intentionally supports only acknowledged test SQLite/filesystem data; production PostgreSQL/provider backup and representative encrypted restore must be executed in the selected environment. +- Local upload paths remain a production durability risk unless mounted to operator-managed persistent storage; the validator requires acknowledgement but cannot prove the mount. +- JWT uses a single signing key; rotation invalidates existing tokens. Revocation/overlap is deferred identity work. +- No job/worker shutdown or recovery semantics exist because there is no worker architecture. +- Docker Compose is a development reference with development defaults, not a production manifest. +- Durable append-only/tamper-resistant administrative events remain exclusively assigned to `implement-platform-audit-events`. +- Distributed state/rate limiting, new hosting infrastructure, distributed tracing, authentication redesign, and application feature work remain out of scope. + +## Recommended next change + +Proceed with `implement-platform-audit-events` as the next application-owned platform-maturity change. Provider-specific deployment acceptance (real PostgreSQL migration, encrypted production-volume restore, proxy topology, external signal collection/alert delivery, and measured RPO/RTO) must be completed as an environment gate and must not be mislabeled as durable audit functionality. + +This phase establishes a tested repository-level operational contract; it does not by itself claim EchoEd is production-ready. diff --git a/docs/operations/production-configuration.md b/docs/operations/production-configuration.md new file mode 100644 index 0000000..b8eef8d --- /dev/null +++ b/docs/operations/production-configuration.md @@ -0,0 +1,31 @@ +# Production Configuration Contract + +Run from `backend` before migration or startup: + +```text +python -m scripts.validate_operational_config +``` + +`APP_ENV=production` prevents dotenv loading and fails closed. Validation messages name categories, never values. + +| Setting | Production contract | +| --- | --- | +| `APP_ENV` | Exactly `production` (other allowed identities: development, test, staging). | +| `DATABASE_URL` | Explicit PostgreSQL URL; placeholder/default credentials and SQLite are rejected. | +| `JWT_SECRET` | Explicit, at least 32 characters, and not a known development value. | +| `ALLOWED_HOSTS` | Explicit DNS/IP hosts; `*`, localhost, and malformed values are rejected. | +| `FRONTEND_URL` | One or more credential-free HTTPS origins. | +| `EXTERNAL_BASE_URL` | Credential-free HTTPS API origin. | +| `TRUST_PROXY_HEADERS` / `TRUSTED_PROXY_IPS` | Disabled by default. Enabling requires explicit peer IP/CIDR entries. Configuring peers without enabling trust is rejected. | +| `STORYBOOK_PATH`, `COLORINGS_PATH`, `BADGES_PATH` | Absolute, distinct persistent paths. | +| `PERSISTENT_STORAGE_ACKNOWLEDGED` | Must be `true`, confirming those paths outlive the process/container. | +| `AUTO_MIGRATE_ON_STARTUP` | Must be false. Migrations are a separate release step. | +| `RELEASE_VERSION`, `DEPLOYMENT_ID` | Explicit immutable artifact version and environment-specific deployment identity. | +| `LOG_FORMAT` | `json`. | +| `METRICS_ENABLED`, `REQUEST_LOGGING_ENABLED` | Both true. | +| `METRICS_ENDPOINT_ENABLED` / `METRICS_ACCESS_TOKEN` | Endpoint may remain disabled; enabling requires a secret token. | +| `GRACEFUL_SHUTDOWN_SECONDS` | Positive integer; default 30. | + +Production must supply values through its secret/configuration mechanism. Do not copy `.env`; do not use Compose defaults. A configuration can pass syntax validation without proving DNS, certificates, storage durability, or database capacity, so deployment preflight still applies. + +Trusted network topology: the direct listener receives traffic either directly, with proxy trust off, or from only the CIDRs in `TRUSTED_PROXY_IPS`. Uvicorn starts with `--no-proxy-headers`; application code considers the first valid forwarded client/protocol/host only when the direct peer is trusted. Host validation remains independent and always enforced. The selected reverse proxy must overwrite, not append untrusted inbound forwarding headers. diff --git a/docs/operations/secret-rotation.md b/docs/operations/secret-rotation.md new file mode 100644 index 0000000..4a1b827 --- /dev/null +++ b/docs/operations/secret-rotation.md @@ -0,0 +1,10 @@ +# Secret and Configuration Rotation + +1. Inventory the credential owner, consumers, expiry, and rollback method without recording values. Prepare a replacement in the target environment's secret manager. +2. Validate a production-equivalent configuration with the replacement using `python -m scripts.validate_operational_config`; do not echo the environment. +3. For database credentials, create/authorize the replacement, deploy dependent instances, verify readiness and representative transactions, then revoke the old credential. For JWT signing, current architecture has one key and no key ring/revocation: rotating invalidates all existing tokens; schedule and communicate reauthentication. +4. For metrics access, update scraper and application in a controlled window; keep the endpoint private/disabled until both sides match. +5. Verify liveness/readiness, authentication, metrics access, logs, and error rates. Revoke the old credential where supported and record only identifier/version/time/result in operational evidence. +6. Emergency rollback restores the prior credential version only if it has not been revoked/compromised; otherwise issue another replacement and contain affected access. + +Development, test, staging, and production use distinct credentials, databases, deployment IDs, origins, hosts, and storage. Production never loads dotenv. The drill runner validates two independently supplied synthetic secrets and emits neither. Multi-key JWT overlap and revocation require a future identity/security change and are not implemented here. diff --git a/docs/operations/service-level-objectives.md b/docs/operations/service-level-objectives.md new file mode 100644 index 0000000..e86b2c1 --- /dev/null +++ b/docs/operations/service-level-objectives.md @@ -0,0 +1,14 @@ +# Initial Service Objectives + +These are 30-day operational targets, not measured production guarantees. Fleet calculation requires external aggregation because Phase 10 metrics reset per process and do not aggregate workers. + +| SLI | Initial target | Source / limitation | +| --- | --- | --- | +| Availability | 99.5% of eligible requests not 5xx while readiness is expected | `echoed_http_requests_total`; exclude operator maintenance only when recorded in advance. | +| Successful request rate | >= 99.0% non-5xx | Status-family counter; expected 4xx is not server failure. | +| Server error rate | < 1.0% 5xx, with no sustained 5-minute breach | HTTP counter and `request.unhandled_exception`. | +| Latency | 95% of ordinary API requests under 1 second | Duration histogram; route-level review is needed and Course Studio import/publish may need separate budgets after production evidence. | +| Readiness | No continuous failure over 5 minutes; >= 99.5% ready checks | Health probes and database readiness metrics. | +| Auth/rate anomalies | Investigate a 3x increase over the prior comparable hour or sustained limiter triggers | Diagnostic anomaly, not a user-performance SLO; process-local counts limit precision. | + +No objective is currently reliable across multiple processes without external metric collection. There is no synthetic regional availability, queue, storage durability, or end-to-end browser SLI. Rebaseline targets after 30 days of representative production-equivalent data. diff --git a/docs/operations/storage-ownership.md b/docs/operations/storage-ownership.md new file mode 100644 index 0000000..4f7813f --- /dev/null +++ b/docs/operations/storage-ownership.md @@ -0,0 +1,12 @@ +# Static and Upload Storage Ownership + +| State | Source of truth | Owner / deployment behavior | Loss consequence | +| --- | --- | --- | --- | +| PostgreSQL | Production database | Data operator backs up/restores | Accounts, courses, memberships, and progress lost/inconsistent. | +| Storybook uploads | `STORYBOOK_PATH` | Storage operator provides persistent mount and coordinated backup | Story assets return 404; DB references remain. | +| Coloring uploads | `COLORINGS_PATH` | Same | Coloring assets unavailable. | +| Badge images | `BADGES_PATH` | Same | Badge images unavailable while records remain. | +| Angular/backend code static files | Immutable artifact/source | Release pipeline rebuilds/redeploys | Restore known-good artifact. | +| Configuration/secrets | Versioned templates + external secret manager | Platform/security operator | Startup validation failure or security incident. | + +Production validation requires absolute, distinct upload paths and an explicit persistence acknowledgement, but cannot prove the mount is durable. Bind mounts in Compose are development behavior. MinIO exists in Compose but application routes do not use it; it is not a backup or source of truth. External object storage, replication, lifecycle management, and CDN/origin isolation remain deferred infrastructure work. diff --git a/docs/platform-maturity/future-openspec-roadmap.md b/docs/platform-maturity/future-openspec-roadmap.md index 4cae0e9..24fdf71 100644 --- a/docs/platform-maturity/future-openspec-roadmap.md +++ b/docs/platform-maturity/future-openspec-roadmap.md @@ -1,6 +1,6 @@ # Future OpenSpec Roadmap -Date: 2026-07-23 +Date: 2026-08-06 ## Prioritization @@ -8,26 +8,28 @@ Priority weighs release criticality, security/privacy risk, architectural depend ## Recommended sequence -1. `harden-platform-security` -2. `establish-platform-observability` -3. `establish-operational-readiness` -4. `implement-platform-audit-events` -5. `implement-curriculum-authoring` -6. `implement-activity-and-assessment-authoring` -7. `implement-content-review-workflow` -8. `implement-content-publishing-lifecycle` -9. `implement-asset-library` -10. `implement-teacher-feedback-and-review` -11. `implement-content-version-history` -12. `implement-notification-infrastructure` -13. `implement-content-search` -14. `implement-community-collaboration` -15. `prepare-echoed-1-0-release` +Completed foundation changes: `harden-platform-security` (Phase 8), `establish-platform-observability` (Phase 10), and repository-scoped `establish-operational-readiness` (Phase 11). + +1. `establish-operational-readiness` +2. `implement-platform-audit-events` +3. `implement-curriculum-authoring` +4. `implement-activity-and-assessment-authoring` +5. `implement-content-review-workflow` +6. `implement-content-publishing-lifecycle` +7. `implement-asset-library` +8. `implement-teacher-feedback-and-review` +9. `implement-content-version-history` +10. `implement-notification-infrastructure` +11. `implement-content-search` +12. `implement-community-collaboration` +13. `prepare-echoed-1-0-release` ## Candidate proposals ### `harden-platform-security` +Phase 8 implementation adds the scoped controls and evidence under `docs/security/`. Remaining distributed rate limiting, durable audit persistence, session revocation, and private/scanned assets stay assigned to later changes; passing this phase does not establish production readiness. + - **Problem/users/value:** Maintainers and every role need authenticated, object-scoped APIs and safe uploads; current forum CRUD is unauthenticated and several administrative/user contracts need minimization and lifecycle invariants. - **Current limitation/support:** `auth.py`, `deps.py`, Phase 6 section scoping, and upload roles provide a base, but `posts.py`/`threads.py` trust caller IDs, rate limiting is absent, and user mutations are broad. - **Required work:** Backend authorization matrix, object ownership/scope enforcement, safe user-role/status mutations, rate-limit strategy, upload verification, error policy; frontend handles explicit denial/limit states without simulated controls. @@ -37,17 +39,21 @@ Priority weighs release criticality, security/privacy risk, architectural depend ### `establish-platform-observability` +Phase 10 implements structured/redacted logs, request/correlation IDs, bounded process-local metrics with protected optional export, database readiness/failure signals, Course Studio events, safe Angular diagnostics, and operational guidance. It does not add distributed tracing infrastructure, alert routing, a commercial vendor, or durable audit persistence. + - **Problem/users/value:** Operators cannot reliably correlate failures or measure availability. -- **Current limitation/support:** Phase 7 adds request IDs, structured key/value request completion logs, and liveness/readiness endpoints; no metrics, tracing, exception aggregation, or privacy-redaction contract exists. +- **Current limitation/support:** The Phase 10 foundation is implemented; external aggregation/alerts, multi-process metric continuity, and distributed traces remain deployment work. - **Required work:** Backend logging schema, auth/authorization outcome events, metrics interfaces, trace propagation, frontend error envelope/reporting hook, redaction tests, and runbooks. No commercial provider is assumed. -- **Authorization/migrations:** Observability endpoints require operator protection; no domain migration expected unless durable incident/event storage is chosen. +- **Authorization/migrations:** Metrics export is disabled by default and token protected; no database migration was required. - **Dependencies/security/privacy:** Follows security threat classification; logs must exclude tokens, learner content, and unnecessary identifiers. -- **Testing/complexity/priority:** Middleware/health failure tests, redaction fixtures, metric cardinality tests; **M / P0 / beta blocker**. +- **Testing/complexity/priority:** Middleware/health failure, redaction, metric cardinality, frontend reference, and domain-event tests provide the Phase 10 gate; **implemented M / P0 foundation**. ### `establish-operational-readiness` +Phase 11 adds fail-closed runtime configuration, explicit host/proxy trust, non-mutating startup, explicit migration/head gates, health/shutdown integration, initial SLO/alert policy, safe backup/restore tooling, storage/rotation ownership, and evidence-driven local drills. Provider-specific alert delivery, backup scheduling, PostgreSQL production recovery measurement, and object storage remain infrastructure dependencies. + - **Problem/users/value:** Maintainers need reproducible deployment, rollback, backup, restore, migration, and secret-rotation procedures. -- **Current limitation/support:** Docker Compose, Alembic, environment files, deterministic demo seed, and health endpoints exist; production hosting, backup/restore, and rollback are unspecified. +- **Current limitation/support:** The repository-owned operational contract is implemented; hosting selection and externally scheduled/aggregated operations remain unresolved. - **Required work:** Select deployment assumptions, validate production config, migration gate, backup/restore drill, static asset ownership, secret rotation, rollback and post-deploy verification. - **Authorization/migrations:** No product authorization change; may add migration safety metadata or operator-only tooling. - **Dependencies/security/privacy:** Depends on hosting decision and security secret policy; backups contain learner data. @@ -59,7 +65,7 @@ Priority weighs release criticality, security/privacy risk, architectural depend - **Current limitation/support:** Request logs and assessment attempt events exist, but no durable actor/action/resource audit model covers role, access, publish, invite, moderation, or destructive actions. - **Required work:** Append-only event model, service interface, minimized read API/UI, retention/export policy, and instrumentation of approved actions. - **Authorization/migrations:** New audit-event table and indexes; platform/org-scoped reads with immutable writes. -- **Dependencies/security/privacy:** Depends on security event taxonomy and observability correlation; metadata must avoid secrets/content leakage. +- **Dependencies/security/privacy:** Depends on the implemented security event taxonomy and request correlation. It must define durable append-only persistence, atomic action/event semantics, actor/action/target/organization, minimized before/after state, retention, restricted operator search/export, privacy, and tamper resistance without treating ephemeral logs as evidence. - **Testing/complexity/priority:** Atomic event/action tests, scope tests, tamper constraints, retention tests; **L / P0 / beta blocker for high-impact admin actions**. ### `implement-curriculum-authoring` diff --git a/docs/platform-maturity/observability-baseline.md b/docs/platform-maturity/observability-baseline.md index 09742bb..5cd6dc6 100644 --- a/docs/platform-maturity/observability-baseline.md +++ b/docs/platform-maturity/observability-baseline.md @@ -1,40 +1,23 @@ # Observability Baseline -Date: 2026-07-23 +Date: 2026-08-07 (Phase 10 update) -## Current state - -| Area | Status | Evidence | +| Area | Current state | Evidence / boundary | | --- | --- | --- | -| Backend logging | Implemented, basic | `backend/app/log.py` configures level and timestamp/name/level/message. Phase 7 request logs use stable key/value fields. | -| Request correlation | Implemented in Phase 7 | Middleware accepts a safe 1–128-character `X-Request-ID` or generates a UUID, exposes it on request state/response, and logs it. | -| Sensitive-log redaction | Partial | Raw tokens, decoded claims, and ORM users were removed from auth logs. No central redaction processor or regression scanner exists. | -| Frontend error handling | Partial | HTTP services and shared state panels handle expected failures; Phase 7 adds recoverable lazy-chunk navigation failure. No centralized exception reporting exists. | -| Liveness | Implemented in Phase 7 | `/health/live` proves process request handling. | -| Readiness/database check | Implemented in Phase 7 | `/health/ready` executes `SELECT 1` and returns 503 without database details on failure. | -| Metrics | Missing | No Prometheus/OpenTelemetry/custom metrics endpoint or SLI definitions. | -| Tracing | Missing | No trace context propagation or spans. Request ID is correlation, not tracing. | -| Error reporting | Missing | Exceptions reach logs; no aggregation, alert, ownership, or incident linkage. | -| Background task visibility | Not currently applicable/partial | No durable application job runner exists; generation-run rows are metadata, not an executing queue. | -| Authentication failure logs | Partial | Privacy-safe failure categories exist; no actor/IP aggregation or rate-limit event. | -| Authorization denial logs | Missing | `require_roles`/`require_org_roles` return 403 without a structured denial event. | -| Publishing/admin action logs | Missing | No durable audit or structured event instrumentation. | - -## Before beta - -- Define request/error/latency and database-readiness SLIs with bounded label cardinality. -- Log authorization denial category and protected resource type without tokens, learner content, or unnecessary personal identifiers. -- Establish exception ownership and alert routing using an approved deployment-compatible mechanism. -- Add privacy-redaction tests and a log retention/access policy. -- Instrument publishing, privilege, access-grant, invitation, and destructive actions through the future audit-event service. - -## Before general availability - -- Adopt vendor-neutral trace/metric interfaces, then bind them to the selected infrastructure. -- Propagate trace/request context across frontend, API, database, and future background jobs. -- Define dashboards, alert thresholds, on-call ownership, incident review, and availability/error-budget policy. -- Measure notification/search/job backlogs if those systems are introduced. - -## Separate OpenSpec work - -`establish-platform-observability` owns metrics, tracing, error aggregation, redaction contracts, and operator runbooks. `implement-platform-audit-events` owns durable actor/action/resource history. Phase 7 intentionally adds no commercial monitoring dependency. +| Backend logging | Foundation implemented | Stable structured events, JSON/developer format, request context, normalized routes, and environment validation. | +| Request correlation | Implemented | Safe request IDs plus a separate bounded correlation hint are returned and propagated through request context. | +| Redaction | Foundation implemented | Central recursive sensitive-key/token/binary redaction and regression tests; call sites must still omit private content. | +| Frontend errors | Foundation implemented | Metadata-only HTTP/global/lazy-chunk diagnostics, safe support references, and accessible existing messages. | +| Liveness/readiness | Implemented | Process liveness is independent of database-backed readiness; responses omit infrastructure details. | +| Metrics | Foundation implemented | Locked process-local bounded metrics; optional token-protected Prometheus text export is disabled by default. | +| Database | Partial | Session/readiness failures and duration are observable without SQL/values; no per-query tracing or pool metrics. | +| Authentication/authorization | Implemented operationally | Stable outcome/denial events and bounded counters without account or protected target labels. | +| Course Studio | Implemented operationally | Draft, conflict, preview, review, duplicate/template/import, and publish boundary events; course content is excluded. | +| Background work | Not currently applicable | No executing worker, scheduler, broker, or queue exists. Generation-run rows are metadata only. | +| Tracing/aggregation/alerting | Deferred | Request correlation is not distributed tracing; no collector, dashboards, or alert ownership is selected. | + +## Remaining production work + +The recommended next change, `establish-operational-readiness`, should bind these vendor-neutral signals to a selected deployment, configure collection/retention/alerts, validate multi-process scraping, and rehearse failure operations. Local metrics reset at process restart and require external aggregation in a multi-process deployment. + +`implement-platform-audit-events` remains separate. It owns append-only persistence, atomic action/event semantics, actor/action/target/organization, minimized before/after security state, retention, restricted search/export, privacy, administrative review, and tamper resistance. Operational logs are potentially ephemeral diagnostic records and cannot satisfy that contract. diff --git a/docs/platform-maturity/operational-readiness-baseline.md b/docs/platform-maturity/operational-readiness-baseline.md index 19ca15a..702a392 100644 --- a/docs/platform-maturity/operational-readiness-baseline.md +++ b/docs/platform-maturity/operational-readiness-baseline.md @@ -32,3 +32,7 @@ Before beta: choose hosting assumptions, validate production config, protect see Before general availability: disaster-recovery targets and drill, capacity/load evidence, maintenance windows, incident ownership, dependency update SLA, and storage lifecycle must be approved. `establish-operational-readiness` should own these actions. This audit does not invent a cloud provider, container orchestrator, managed database, or commercial service. + +## Phase 11 disposition + +The active `establish-operational-readiness` change implements the repository-owned portions of this baseline. Canonical behavior and evidence now live under `docs/operations/`, beginning with `phase-11-baseline.md` and `phase-11-operational-readiness-verification.md`. Provider selection, external alert delivery/aggregation, production backup scheduling, production-scale recovery measurement, and object-storage infrastructure remain explicit dependencies rather than implied application capabilities. diff --git a/docs/platform-maturity/security-baseline.md b/docs/platform-maturity/security-baseline.md index 91eaf89..baece30 100644 --- a/docs/platform-maturity/security-baseline.md +++ b/docs/platform-maturity/security-baseline.md @@ -39,3 +39,7 @@ This is a focused repository review, not a penetration test. Severity reflects p ## Required next actions Before beta, either secure or disable the forum routes, finish privileged user mutation constraints, establish rate limits appropriate to the deployment topology, retain patched production dependencies, and implement durable audit events for high-impact actions. Before general availability, complete session/revocation policy, asset scanning/storage isolation, CSP/HSTS at the serving edge, retention/privacy review, and a formal threat model. + +## Phase 8 follow-up + +The `harden-platform-security` change addresses the first three actions with authenticated owner-scoped forum writes, explicit privileged-user schemas/invariants, and configurable single-process rate limits. The current threat model and residual risks are canonical in [docs/security](../security/phase-8-threat-model.md). Durable audit storage, distributed limiter storage, session revocation, private/scanned assets, and serving-edge policy remain open. diff --git a/docs/security/administrative-response-minimization.md b/docs/security/administrative-response-minimization.md new file mode 100644 index 0000000..e4d1fa2 --- /dev/null +++ b/docs/security/administrative-response-minimization.md @@ -0,0 +1,12 @@ +# Administrative Response Minimization + +| Endpoint | Previous exposure | New fields | Removed / compatibility | +| --- | --- | --- | --- | +| `GET /users` | raw ORM `User`, including `hashed_password`, update timestamp, and future relationships | `id`, `firstname`, `lastname`, `username`, `email`, `role`, `created_at` | password hash, `updated_at`, relationships, future model fields; Angular Admin uses retained fields | +| `GET /users/{id}` | raw ORM `User` | same explicit detail summary | same removals; Admin detail compatible | +| `GET /users/students` | raw ORM users and contact/security fields | `id`, names, `username`, `role` | email, hash, timestamps, relationships; teacher selectors use identity fields only | +| `POST /orgs/{id}/invites` | full invite including bearer token | invite metadata plus token once for current no-email distribution workflow | no unrelated organization/user graph | +| `GET /orgs/{id}/invites` | bearer token on every list read | id, org id, email, role, expiry, acceptance timestamp, inviter id | `token`; Angular status views remain compatible | +| `GET /orgs/{id}/members` | already minimal | membership id, user id, display name, username, org role/status/joined date | email, global platform role, hash, relationships remain absent | + +All changed response models are explicit Pydantic schemas and prevent future ORM fields from serializing accidentally. Platform-global user summaries and organization-member summaries remain distinct. Backend schema tests and Angular consumer tests provide evidence. diff --git a/docs/security/administrator-safety-controls.md b/docs/security/administrator-safety-controls.md new file mode 100644 index 0000000..19e62fb --- /dev/null +++ b/docs/security/administrator-safety-controls.md @@ -0,0 +1,14 @@ +# Administrator Safety Controls + +| Action against self | Decision | +| --- | --- | +| Change/remove own platform role | 409; blocked | +| Grant own additional platform permission | impossible through allowlisted role-only endpoint; blocked | +| Delete own account | 409; blocked | +| Deactivate/reset own security state | endpoint not implemented | +| Remove own organization membership/change org scope | endpoint not implemented | +| Ordinary profile/preferences editing | unchanged | + +The protected final administrator is the last persisted `super_admin`, not the last `admin`. Demotion or deletion locks the target and the matching super-admin rows where the database supports `SELECT FOR UPDATE`, counts them inside the mutation transaction, and returns 409 if no super administrator would remain. With two or more, an authorized different super admin may demote/delete one. The current account model has no activation/usable flag, so persisted records are counted; this must change if account lifecycle is added. + +EchoEd does not impose a final organization-admin invariant in Phase 8. Personal organizations and existing organization lifecycle do not establish that product requirement, and there is no membership-removal endpoint. Frontend prevention improves clarity but the backend is authoritative. See `test_security_hardening.py` for self, final, and multiple-admin cases. diff --git a/docs/security/forum-mutation-hardening.md b/docs/security/forum-mutation-hardening.md new file mode 100644 index 0000000..ba3987d --- /dev/null +++ b/docs/security/forum-mutation-hardening.md @@ -0,0 +1,18 @@ +# Forum Mutation Hardening + +Public thread/post reads remain intentional for the current public Community preview. Every implemented write now fails closed at the backend. + +| Endpoint/capability | Before | After | +| --- | --- | --- | +| `POST /api/forum/threads` | anonymous; trusted `user_id` | authenticated; owner is current user; rate-limited | +| `PUT /api/forum/threads/{id}` | anonymous; could replace owner | author or `admin`/`super_admin`; title only; owner immutable | +| `DELETE /api/forum/threads/{id}` | anonymous | author or platform moderator; moderator action logged | +| `POST /api/forum/posts` | anonymous; trusted `user_id` and any parent | authenticated; existing thread required; owner server-derived; rate-limited | +| `PUT /api/forum/posts/{id}` | anonymous; could move parent/owner | author or moderator; content only; parent/owner immutable | +| `DELETE /api/forum/posts/{id}` | anonymous | author or moderator; moderator action logged | +| `GET` thread/post collection/detail | public | public, unchanged | +| reactions/votes/reports | absent | disabled (no route; 404) | +| pin/lock/hide/restore/moderation | absent | disabled except moderator edit/delete on implemented records | +| forum attachments/uploads | absent | disabled; generic content uploads cannot be attached by forum payload | + +There is no organization/community field in the current thread/post model, so Phase 8 does not pretend to provide organization-scoped forums. Organization forums, memberships, reporting, reactions, and richer moderator roles require a future community contract. Evidence: `backend/tests/test_forum_endpoints.py` covers 401, allowed author writes, immutable ownership, 403 non-owner denial, moderator deletion, and public reads. diff --git a/docs/security/object-authorization-audit.md b/docs/security/object-authorization-audit.md new file mode 100644 index 0000000..73cbe16 --- /dev/null +++ b/docs/security/object-authorization-audit.md @@ -0,0 +1,21 @@ +# Object-Level Authorization Audit + +| Resource family | Required checks / Phase 8 disposition | Evidence or residual work | +| --- | --- | --- | +| Users | platform role, target hierarchy, self/final-admin, explicit fields | hardened; admin/security tests | +| Organizations/memberships/invites | active membership, header/path match, grant allowlist | hardened high-risk paths; org tests | +| Sections | active educator org membership; section organization; course version belongs to org or global catalog | hardened; two-org tests | +| Assignments/lesson sessions | scoped section, unit/lesson belongs to section course version, learner enrollment for submission | hardened parent mismatches; tests cover session paths | +| Courses/programs | existing course-authoring/V2 organization policy; public/published reads deliberate | retained; prior course permission/authoring suites | +| Units/lessons/activities | resolve child to owning course and re-check authoring capability for every mutation, including moves | hardened mutations; content-admin cross-org test | +| Assessments/attempts | student attempt identity is server-owned; staff creation remains legacy global staff scope | existing assessment phase suites; full org-local assessment model deferred | +| Learner progress | resolve unit/segment to student course; learner owns record; teacher must own/be enrolled in section; admin deliberate | hardened direct ID; learner IDOR test | +| Badges/certificates | student own records or explicit admin/super admin; student certifications use `/me` | explicit role cleanup; existing suites | +| Forum | authenticated server-owned author; owner/moderator; immutable parent/owner | hardened; forum suite | +| Uploads | role-gated create only; no client object ID, replacement, or deletion | hardened binary boundary; ownership model deferred | +| V2 workspaces/projects/products/reviews/access grants | existing organization/workspace resolvers and creator/reviewer policies | audited and retained; V2 wrapper tests | +| Reports/analytics | existing scoped section helper for org summary; global analytics roles remain deliberate legacy contract | section cross-org tests; unified reporting scope remains future work | + +Policy: cross-organization or parent-mismatch lookups return 404 to conceal the protected object. A known object denied only by actor capability returns 403. Invalid identifiers use 400/422; absent resources use 404. A valid UUID never substitutes for authorization. + +Not every legacy read is private: published course, program, certification, forum, and catalog reads retain product visibility. Phase 8 focuses protected records and mutations rather than redesigning catalog publication. diff --git a/docs/security/organization-isolation-verification.md b/docs/security/organization-isolation-verification.md new file mode 100644 index 0000000..1f61250 --- /dev/null +++ b/docs/security/organization-isolation-verification.md @@ -0,0 +1,20 @@ +# Organization Isolation Verification + +The backend tests create at least two schools with separate administrators/educators/learners, memberships, courses, course versions, sections, enrollments, lessons, sessions, invites, and progress/content records. Direct API identifiers and mismatched parent IDs are exercised rather than relying on Angular navigation. + +| Attempt from Organization A against B | Result / evidence | +| --- | --- | +| List B members using A header | 403 path/header mismatch; `test_org_admin_scope.py` | +| Read B sections | only A sections returned; nested B section IDs concealed 404 | +| Invite to B | active membership plus header/path match required; grant role allowlist | +| Enroll non-member into A section | rejected; active same-org membership required | +| Start/end B lesson session | concealed 404; session query joins section organization | +| Combine A section with B lesson | concealed 404 parent mismatch | +| Create A section from B course version | concealed 404 | +| Mutate B unit as A content admin | 403 course-authoring capability denial | +| Read B learner progress by direct ID | learner ownership/teaching-section resolver returns 404 | +| Read B uploads | no organization ownership exists; paths are public by current product contract and recorded as a limitation | +| Mutate B forum content | forum is not organization-scoped; non-owner receives 403 unless platform moderator | +| B administrative reports | scoped section analytics conceals B section; global legacy analytics remains platform/teacher role based and needs future unified reporting scope | + +The suite also retains V2 workspace/project/product organization-scope tests. Frontend route guards are not counted as isolation evidence. Remaining limitations arise where the data model itself lacks organization ownership (forum/uploads) and are not described as isolated. diff --git a/docs/security/phase-8-security-baseline.md b/docs/security/phase-8-security-baseline.md new file mode 100644 index 0000000..8b032c4 --- /dev/null +++ b/docs/security/phase-8-security-baseline.md @@ -0,0 +1,42 @@ +# Phase 8 Security Baseline + +Date: 2026-08-06 + +This snapshot was recorded before Phase 8 behavior changes. It is a repository review, not a penetration test and not a production-readiness claim. + +## Repository state + +- Branch: `aqw-echoed-dev` +- Commit: `c6336fa79458c08da75746954615a86522e766a8` +- Phase 7 change: `establish-platform-maturity-foundation` is complete (21/21 tasks) and passes `openspec validate establish-platform-maturity-foundation --strict`. +- Phase 7 archive status: not archived. It remains at `openspec/changes/establish-platform-maturity-foundation/`; no archived OpenSpec file will be modified by Phase 8. +- Working tree: dirty before Phase 8. Modified and untracked files belong to the separate completed `unify-course-authoring-experience` work and are preserved. The pre-existing paths are the course route/schema/governance and Angular Studio/environment files, the new course-authoring backend modules and tests, the new Course Studio frontend files/test, course-authoring documentation, and that OpenSpec change. + +## Authentication and authorization + +- Authentication uses bcrypt password hashes and signed HS256 JWT bearer access tokens. `JWT_SECRET` is mandatory at import/startup and tokens expire after 120 minutes. The database user is resolved again for every authenticated request. There is no refresh, revocation, session, MFA, OAuth, or SSO system. +- Registration and `/auth/token` are public. At baseline, registration trusts a caller-provided global role and login returns `400` for invalid credentials. Neither route is rate-limited. +- Backend authorization is primarily implemented through `require_roles(...)`, `require_org_roles(...)`, and route-local object checks. Checks are explicit in some mature organization/section/V2 routes but inconsistent across legacy routes. Angular guards and hidden controls are navigation aids only, not security boundaries. +- Supported global role strings found in runtime code and seed data are `student`, `teacher`, `content_admin`, `org_admin`, `admin`, `super_admin`, and the legacy-compatible `instructor` role. Organization roles are `org_admin`, `content_admin`, `teacher`, `parent`, `student`, `instructor`, `viewer`, and `super_admin`. +- Organization scoping uses an authenticated user membership plus `X-Org-Id`. `require_org_roles` requires an active membership. Several route families add parent-resource checks. A `super_admin` bypass exists, but its baseline implementation can return another user's membership and is not a sound canonical platform-scope primitive. + +## Sensitive surfaces + +- Rate limiting: none. There is no middleware, dependency, shared store, `429` contract, or trusted-proxy configuration. +- Uploads: `/api/upload/coloring` and `/api/upload/storybook` allow global `admin` or `teacher`; `/api/upload/badge` allows `admin`. The server uses UUID filenames, a 5 MiB streamed limit, an extension/content-type allowlist, atomic `.part` completion, and cleanup. It does not validate magic bytes, strip metadata, bind ownership/organization, or isolate served files from the application origin. Static upload directories are public. +- Forum access: thread/post reads and all thread/post mutations are public. Mutations trust caller-supplied `user_id`; updates can replace ownership. There are no organization, membership, owner, moderator, reaction, report, attachment, or moderation controls. +- Privileged user management: `GET /api/users`, `GET /api/users/{id}`, `PUT /api/users/{id}`, and `DELETE /api/users/{id}` require global `admin`. `GET /api/users/students` allows `admin` and `teacher`. List/detail endpoints return ORM objects without response schemas, exposing fields such as `hashed_password`; update accepts the registration DTO including password and arbitrary role. There are no explicit target hierarchy, self-action, mass-assignment, final-admin, organization, or audit controls. +- Organization user management: organization admins can list members and create/list invitations within the active organization. Invite responses expose acceptance tokens. Invite role construction is enum-bounded but includes `super_admin`; there is no narrowed organization-admin grant allowlist or rate limit. + +## Existing security evidence and test baselines + +- Existing focused tests cover authentication-required diagnostics, response headers/request IDs, upload MIME/size rejection, active organization membership, organization scope, direct user admin operations, and the current anonymous forum behavior. +- The verified Phase 7 baseline is 233 backend tests, 287 Angular tests, and 19 Playwright tests. Phase 7 itself cites the earlier minimums of 228, 284, and 16 respectively. +- The current dirty course-authoring work adds legitimate tests beyond Phase 7. Before Phase 8, its recorded verification was 294 passing Angular tests, 84 passing targeted backend tests, one passing Course Studio browser smoke, a passing production build, strict OpenSpec validation, and `git diff --check`. A complete current backend and Playwright baseline had not yet been re-established after those unrelated changes and must be measured during Phase 8 verification; no unexplained collection reduction is acceptable. + +## Deployment and configuration baseline + +- The checked-in deployment starts one Uvicorn process, so a process-local limiter can enforce limits for that topology. It will not coordinate across multiple API processes or hosts. +- Forwarded client-IP headers are not explicitly trusted or parsed by the application. The direct socket peer is the only safe anonymous key until an explicit trusted-proxy policy exists. +- CORS uses configured explicit origins but permits all methods and headers with credentials. API responses include request correlation, `nosniff`, frame denial, and `no-referrer`. +- No durable privileged-action audit ledger exists. Privacy-safe request logging is available and can carry structured security events as an interim control. diff --git a/docs/security/phase-8-security-verification.md b/docs/security/phase-8-security-verification.md new file mode 100644 index 0000000..6e4b6d4 --- /dev/null +++ b/docs/security/phase-8-security-verification.md @@ -0,0 +1,54 @@ +# Phase 8 Security Verification + +Date: 2026-08-06 + +`harden-platform-security` is implemented and strictly valid. This evidence confirms the bounded controls in the change; it is not a penetration-test result or a claim that EchoEd is fully secure or production-ready. + +## Preconditions and scope + +- Phase 7 `establish-platform-maturity-foundation` remains complete (21/21 tasks) and strictly valid. +- Phase 7 is not archived; its active artifacts were not modified. +- Work began on branch `aqw-echoed-dev` at commit `c6336fa79458c08da75746954615a86522e766a8` with separate course-authoring work already dirty. Those user-authored files were preserved and the Phase 8 implementation was kept within security, compatibility, test, and documentation boundaries. +- No database migration was required. The actual account model has no active/disabled field, so a usable highest administrator means a persisted `super_admin`. Organization final-admin enforcement was deliberately not invented because EchoEd does not require every organization to retain an administrator. + +## Control evidence + +- Public forum reads remain. Thread/post create, update, and delete require authentication; ownership is server-derived and immutable; author-or-platform-moderator rules are enforced; unsupported reactions, votes, reports, pin/lock, hide/restore, attachments, and broader moderation remain absent/disabled. +- Central backend role allowlists distinguish platform, organization, teaching, content, learner, forum-owner, and moderator authority. Unknown/broad negated-role authorization was not introduced. +- Platform user list/detail/role/delete operations use explicit schemas and actor/target hierarchy. Admins cannot grant platform roles or modify platform administrators. Self-role changes, self-delete, self-escalation, mass assignment, and final-super-admin demotion/deletion fail with actionable conflicts. +- Active organization membership is required for organization operations. Invitation roles are organization-safe; invitation tokens are returned once on creation but removed from list responses. Cross-organization content, section, lesson-session, assignment, and progress identifiers are parent/scope checked and concealed where documented. +- Central fixed-window policies cover login, registration, invitation management/acceptance, uploads, forum writes, and platform user mutations. Limits are environment-configurable, return `429` plus `Retry-After`, use direct socket peer/account/user keys, and intentionally ignore forwarded client-IP headers. +- Existing uploads retain streamed 5 MiB limits, UUID storage names, atomic completion, and explicit format/MIME allowlists. Raster signatures and dimensions/pixel counts are now validated; SVG, traversal, signature mismatches, unauthorized actors, and excess request rates are rejected. +- Privacy-safe structured events cover authentication failures, privileged user/role/delete actions, final-admin blocks, invitation actions, upload rejection, rate-limit triggers, and forum moderation where existing logging has sufficient context. +- Angular maps safe `401`, `403`, concealed `404`, `409`, `422`, and `429` messages, logs out expired sessions, preserves throttled form content, restricts role options, handles reduced schemas, and announces errors accessibly. + +## Verification results + +| Check | Result | +| --- | --- | +| Phase 7 strict validation | Pass; complete 21/21 and strictly valid | +| Backend complete suite | Pass; **269 passed**, 4,169 existing deprecation warnings; Phase 7 baseline 233 | +| Backend syntax/dependency checks | Pass; `compileall` clean and `pip check` reports no broken requirements | +| Backend format/lint | No formatter or linter is configured/installed by the repository; no substitute dependency was added | +| Angular complete suite | Pass; **299 passed**; Phase 7 baseline 287 | +| Playwright complete suite | Pass; **22 passed**; Phase 7 baseline 19 | +| Production Angular build | Pass; initial 437.82 kB / estimated 118.83 kB, output `dist/echoed-frontend` | +| Production dependency audit | Pass; `npm audit --omit=dev` reports **0 vulnerabilities** | +| Full npm audit | Finding; 31 development-tool vulnerabilities (3 low, 7 moderate, 19 high, 2 critical), documented in the configuration review | +| Phase 8 strict validation | Pass; `openspec validate harden-platform-security --strict` | +| Whitespace validation | Pass; `git diff --check` with line-ending conversion warnings only | + +The full Playwright run used a disposable seeded SQLite database and a temporary local static SPA/API-proxy harness because the drive exhausted free space and `ng serve` could not allocate its build heap. The production build itself passed before this harness was used. One existing focus check was made sequence-stable by advancing through at most five keyboard focus targets without sleeps. The final run passed 22/22. Ports 8000 and 4200 were stopped, and the database, harness, Playwright results, and generated caches were removed. + +## Residual risk and deferred work + +- Rate-limit state is process-local and resets on restart; multi-process/host deployment requires a shared store and an explicit trusted-proxy policy. +- JWTs remain long-lived bearer tokens without refresh, revocation, MFA, OAuth, or SSO. Those identity-architecture changes remain non-goals. +- Uploaded files remain publicly served from API static paths; malware scanning, metadata stripping, private object storage, signed delivery, and organization-bound asset ownership remain future work. +- Security events are ordinary logs, not a durable, queryable, tamper-resistant audit ledger. +- The domain model still lacks account activation/security-state fields; therefore deactivation/reset/impersonation routes remain unsupported rather than partially implemented. +- Some legacy resources (notably assessments, certificates, reports, and uploads) lack complete organization ownership metadata. Existing accessible operations are role/parent checked where the model permits; schema-backed end-to-end isolation requires later domain work. +- Trusted-host policy, HSTS/CSP, production OpenAPI policy, secret rotation, backup/restore proof, and production-mode configuration validation remain deployment-hardening work. +- Development-only npm findings remain; production dependencies audit clean. Local/CI toolchains must remain non-public and avoid untrusted project inputs until a bounded dependency remediation change lands. + +The recommended next OpenSpec change remains `establish-platform-observability`, followed by `implement-platform-audit-events` once correlation and operational telemetry are established. diff --git a/docs/security/phase-8-threat-model.md b/docs/security/phase-8-threat-model.md new file mode 100644 index 0000000..94e4b81 --- /dev/null +++ b/docs/security/phase-8-threat-model.md @@ -0,0 +1,18 @@ +# Phase 8 Threat Model + +Date: 2026-08-06. Scope: repository trust boundaries, not a penetration test. Frontend guards and hidden controls are never counted as backend authorization controls. + +| Protected resource | Actor / entry point | Required permission and scope | Abuse scenario | Existing control before Phase 8 | Phase 8 mitigation | Residual risk / test evidence | +| --- | --- | --- | --- | --- | --- | --- | +| JWT account session | Anonymous; `/auth/token`, `/auth/register` | Public issuance with server-owned authority | Brute force, enumeration, public admin registration | bcrypt/JWT only; caller role trusted | Generic 401, safe registration-role allowlist, account+peer limits | No revocation/MFA; `test_security_hardening.py` auth/registration tests | +| Platform users | Admin/super admin; `/users*` | Explicit platform admin plus target hierarchy | Read password hash, mass assignment, self-escalation, delete last administrator | broad `admin`, ORM serialization, full DTO | explicit schemas, role-only update, admin/super hierarchy, self/final-super-admin conflicts, limits/events | No account-active model; focused admin tests | +| Organization members/invites | Org admin; `/orgs/{id}/*` | active org-admin membership matching path/header | Cross-org IDOR, grant platform role, leak invite tokens | active membership on dependency; tokens returned in all responses | grant allowlist, path/header match, token omitted from list, one-time create response, limits | No email delivery/revocation; org-scope tests | +| Forum threads/posts | Anonymous/authenticated; `/forum/*` | public read; authenticated create; author or platform moderator update/delete | spoof author ID, overwrite ownership, anonymous spam/delete | no mutation auth or ownership | server-derived owner, immutable parent/owner, owner/moderator policy, rate limit | No org communities/moderation product; forum tests | +| Courses/units/lessons/activities | staff/client IDs | course authoring capability for owning org/course | Content admin or teacher mutates another org by ID | global role checks on legacy child routes | shared course resolution plus existing course-authoring capability checks | Some public/catalog reads remain product-visible; cross-org content-admin test | +| Sections/lessons/assignments | org educator/client IDs | active org role, section org, content parent | combine own section with another org lesson/target | section helper existed; lesson sessions lacked it | section + course-version + unit/lesson parent validation; scoped session end; assignment enrollment check | Legacy target types remain UUID-only; org tests | +| Learner progress | learner/teacher/client progress IDs | own learner record or authorized teaching section | learner reads/completes another learner's progress | role check only on several routes | progress-to-student-course resolver, learner ownership, teacher-section visibility, concealed 404 | Course assignment APIs need future unified teaching policy; direct-ID test | +| Images | authorized uploader; multipart routes | explicit role; safe bounded raster | polyglot/active file, traversal, decompression bomb, flood | UUID name, MIME/suffix, 5 MiB stream | magic/dimension/pixel validation, atomic storage, rate limit, rejection event | no malware scan/metadata stripping/private object store; upload tests | +| Cross-org V2/reports | org roles; workspace/project/analytics routes | active membership and parent ownership | read another org workspace/report | mature route-local scope helpers | audited; existing tests retained, security policy documents concealment rule | Broad route family remains complex; future property-based matrix advised | +| Security logs | operators | privacy-safe operational access | tokens/passwords/learner content enter logs | request correlation and redacted auth logs | structured event vocabulary with IDs/reason only | process logs are not durable audit records | + +Trust crosses browser/API, bearer token/database identity, global role/organization membership, parent/child identifiers, multipart parser/filesystem, reverse proxy/socket peer, and application/operator logging boundaries. Highest-risk residual items are shared rate-limit storage for scaled deployment, private asset serving/scanning, durable audit retention, and session revocation. diff --git a/docs/security/privileged-user-management-review.md b/docs/security/privileged-user-management-review.md new file mode 100644 index 0000000..4ea9b9f --- /dev/null +++ b/docs/security/privileged-user-management-review.md @@ -0,0 +1,24 @@ +# Privileged User-Management Review + +## Platform routes + +| Endpoint | Actor | Target/fields | Invariants | Response | +| --- | --- | --- | --- | --- | +| `GET /users` | `admin`, `super_admin` | all platform users | read-only | explicit platform summaries | +| `GET /users/{id}` | `admin`, `super_admin` | one platform user | 404 absent | explicit platform detail summary | +| `GET /users/students` | `teacher`, `admin`, `super_admin` | students | read-only; no contact data | student summary | +| `PUT /users/{id}` | `admin`, `super_admin` | `role` only | no self action; admins cannot target/grant platform admins; final super admin retained | message only | +| `DELETE /users/{id}` | `admin`, `super_admin` | target account | no self deletion; target hierarchy; final super admin retained | message only | + +Unsupported routes for activation/deactivation, credential reset, impersonation, membership mutation/removal, and admin revoke/grant outside role update remain absent (404/405). Phase 8 does not invent them. + +## Policy decisions + +- `super_admin` is the highest platform role; `admin` is a compatible lower platform-administrator role. +- Admins may manage non-platform roles but cannot grant `admin`/`super_admin`, change another admin, or target a super admin. Super admins may do so except against themselves. +- Public registration may select only supported non-administrative onboarding roles (`student`, `teacher`, `instructor`, `parent`); any other value becomes `student`. +- Organization admins grant only organization roles and cannot grant `super_admin`. Organization invite lists omit bearer tokens; a newly created invitation returns its token once because no mail delivery exists. +- Updates forbid extra fields, so password, IDs, organization scope, names, email, timestamps, and ORM relationships cannot be mass-assigned. +- No active/deactivated column exists; all persisted super admins count as usable. Adding lifecycle state must extend the invariant transactionally. + +High-impact successes/final-admin failures emit privacy-safe structured events. Evidence is in `test_admin_users.py`, `test_security_hardening.py`, `test_org_admin_scope.py`, and Angular admin tests. diff --git a/docs/security/rate-limiting-policy.md b/docs/security/rate-limiting-policy.md new file mode 100644 index 0000000..98fdd54 --- /dev/null +++ b/docs/security/rate-limiting-policy.md @@ -0,0 +1,19 @@ +# Rate-Limiting Policy + +All limits use the central fixed-window implementation in `app.rate_limit`; values are configurable with `RATE_LIMIT__LIMIT` and `RATE_LIMIT__WINDOW_SECONDS`. + +| Group / protected endpoints | Default | Key | Failure | +| --- | ---: | --- | --- | +| `auth_login` `/auth/token` | 10 / 60 s | direct peer + normalized account identifier | generic 429 + `Retry-After` | +| `auth_register` `/auth/register` | 5 / 3600 s | direct peer + normalized username | 429 + retry metadata | +| `invite_accept` `/invites/accept` | 10 / 300 s | authenticated user ID | generic invitation/rate response | +| `invite_manage` create invite | 10 / 60 s | authenticated user ID | 429 | +| `upload` all three image uploads | 20 / 60 s | authenticated user ID | 429 before file storage | +| `forum_mutation` thread/post writes | 30 / 60 s | authenticated user ID | 429 before mutation | +| `user_management` role/delete | 20 / 60 s | authenticated user ID | 429 before mutation | + +The store is locked process memory. The checked-in deployment starts one Uvicorn process, so this is effective for that topology and resets on restart. It is not distributed across workers/hosts; a shared non-commercial store is mandatory before scaling. Limit evaluation for protected writes is fail-closed because configuration errors raise at evaluation rather than silently disabling the control. + +Only the direct socket peer is used. `X-Forwarded-For` and similar headers are ignored because trusted proxies are not configured. Behind a reverse proxy, authenticated routes remain keyed per user; anonymous account+peer keys can group callers behind the proxy but resist spoofing. Configure trusted proxy handling and a shared store together in future deployment work. + +Tests cover independent keys, exact 429, `Retry-After`, reset after a window, environment overrides, authentication abuse, and upload abuse. Playwright does not use timing-heavy limiter loops. diff --git a/docs/security/role-authorization-policy.md b/docs/security/role-authorization-policy.md new file mode 100644 index 0000000..30ea03c --- /dev/null +++ b/docs/security/role-authorization-policy.md @@ -0,0 +1,16 @@ +# Canonical Role Authorization Policy + +Backend constants in `app.security` are canonical. Unknown roles are denied; route declarations are validated against explicit allowlists. `admin` and `super_admin` are distinct platform roles. `teacher` and `instructor` are compatible teaching roles, not aliases for administration. Organization authority requires an active membership and is separate from a global role. + +| Role | Platform scope | Organization scope | User management | Content / teaching / learner | Forum | Prohibited actions | +| --- | --- | --- | --- | --- | --- | --- | +| `student` | own session | student membership | none | own enrollment/progress | create; own edit/delete | admin/content/other learner records | +| `parent` | onboarding/session only | parent membership | none | no implied teaching | create; own edit/delete | administrative grants | +| `teacher` / `instructor` | no platform admin | active teaching membership | student summaries only | authorized sections/learners; owned legacy or scoped content | create; own edit/delete | cross-org content/users, platform roles | +| `content_admin` | no platform admin | active content membership | none | organization course authoring; no learner/user administration | create; own edit/delete | platform user management | +| `org_admin` | no platform admin | active organization administration | organization members/invites only | org sections/content; no global users | create; own edit/delete | grant platform roles/cross-org access | +| `admin` | platform administrator | only deliberate org paths | non-admin targets; cannot grant/target platform admins | broad compatible content/admin access | moderator | self role/delete, target admin/super admin | +| `super_admin` | highest platform scope | deliberate explicit bypass | all targets except self, subject to final-super invariant | broad deliberate access | moderator | self role/delete, remove final super admin | +| `viewer` (org only) | none | read permissions only where implemented | none | no mutation implication | normal authenticated author only | administrative/content mutation | + +Reusable boundaries are `require_roles`, `require_org_roles`, `require_scoped_section`, content-to-course authoring checks, forum owner/moderator checks, and progress-to-student-course checks. Known authenticated denials use 403; cross-organization/object concealment uses 404. A platform role never substitutes for an organization role unless a route deliberately includes the super-admin path. diff --git a/docs/security/security-configuration-review.md b/docs/security/security-configuration-review.md new file mode 100644 index 0000000..f036ce9 --- /dev/null +++ b/docs/security/security-configuration-review.md @@ -0,0 +1,21 @@ +# Security Configuration and Dependency Review + +| Area | Evidence / decision | +| --- | --- | +| Authentication | bcrypt plus `python-jose` HS256; mandatory `JWT_SECRET`; 120-minute bearer; no refresh/revocation/MFA (deferred) | +| Passwords | bcrypt direct API; no plaintext/token logging; 72-byte bcrypt behavior remains documented | +| Multipart/uploads | existing `python-multipart`; no new dependency; bounded raster structural validation | +| Rate limiting | standard-library locked memory; environment-configurable; no commercial/new package; single-process limitation documented | +| Proxy | forwarding headers ignored; direct peer only; no implicit trust of spoofable headers | +| CORS/hosts | explicit configured origins; all methods/headers with credentials retained for client compatibility; TrustedHost middleware not configured | +| Headers | `nosniff`, `DENY` frame policy, `no-referrer`, request ID; HSTS/CSP remain serving-edge work | +| Cookies/session | bearer Authorization token, not auth cookie; CSRF risk changes if cookies are introduced | +| Environment | `JWT_SECRET` fails fast; rate-limit numeric overrides fail on invalid/non-positive values when evaluated; local CORS defaults must not be production defaults | +| Debug/OpenAPI | FastAPI debug is not enabled by code; OpenAPI remains exposed for current developer/evaluation use | +| Test bypasses | dependency overrides and test JWT secret stay in test code; shared demo credentials are explicitly non-production | +| Logging | privacy-safe request/security events; no durable audit store | +| Dependencies | no backend dependency added/removed/upgraded; Angular runtime/compiler packages moved narrowly from 20.3.25 to 20.3.27 to remediate the production dependency advisories found during verification; the lockfile was regenerated/normalized | + +Production must supply a strong rotated secret, explicit `FRONTEND_URL`, single API process until shared rate limiting exists, TLS/HSTS at the edge, restricted deployment access, and non-demo credentials/data. A future deployment-hardening change should validate production-mode defaults, trusted hosts/proxies, OpenAPI policy, CSP/HSTS, secret rotation, backups, and distributed rate limiting. + +`npm audit --omit=dev` reports zero production vulnerabilities after the Angular patch. The full audit still reports 31 development-tool findings (3 low, 7 moderate, 19 high, 2 critical), primarily through Angular CLI/build-server, Karma, archive, proxy, and bundler dependencies. They are not shipped in the production browser bundle, but local/CI tools must not process untrusted projects or expose development servers. Remediation is deferred to a bounded toolchain dependency change because the available fixes update a broad transitive build tree and are outside this focused runtime-boundary phase. diff --git a/docs/security/security-error-response-policy.md b/docs/security/security-error-response-policy.md new file mode 100644 index 0000000..47fdf00 --- /dev/null +++ b/docs/security/security-error-response-policy.md @@ -0,0 +1,10 @@ +# Security Error Response Policy + +- `401 Unauthorized`: missing, expired, invalid bearer token or invalid login credentials. Authentication messages are generic. +- `403 Forbidden`: authenticated actor is known but an explicit role/capability/ownership policy denies the action. Target detail is minimized. +- `404 Not Found`: absent resource or deliberate concealment of cross-organization, parent-mismatch, learner-record, or inactive-membership targets. +- `409 Conflict`: action is structurally valid but violates administrator safety or invitation lifecycle state. +- `422 Unprocessable Entity`: Pydantic validation, forbidden extra/mass-assignment fields, or unsupported role value. +- `429 Too Many Requests`: central limiter denial with integer `Retry-After` seconds and a generic safe message. + +Denied operations never return success, stack traces, SQL, password hashes, tokens, or internal exception text. FastAPI validation details may identify rejected field names but not stored target data. Legitimate administrators receive actionable final-admin/self-action messages. Angular maps these statuses to safe accessible text; only deliberate 409/422 backend detail strings are displayed. diff --git a/docs/security/security-event-logging.md b/docs/security/security-event-logging.md new file mode 100644 index 0000000..6df2d52 --- /dev/null +++ b/docs/security/security-event-logging.md @@ -0,0 +1,9 @@ +# Security Event Logging + +Phase 8 security events now flow through the Phase 10 structured, recursively redacted application logger. `security_event` writes request/correlation ID, actor ID (or anonymous), action, target type/ID when authorized, result, and safe reason code; bounded action/result counters provide aggregate operational visibility. + +Implemented events include authentication failure, limiter triggers, platform role changes, user deletion, final-super-admin protection failures, organization invitation creation, upload rejection, and moderator forum deletion. Existing request logs also record cross-organization denial status/path correlation without response bodies. + +Never log passwords, bearer/invitation/reset tokens, uploaded bytes, filenames supplied by users, learner content, decoded JWT payloads, or unnecessary email/profile data. IDs are operational identifiers, not permission to expose associated records. + +These logs are diagnostic, potentially ephemeral, and intended for monitoring and incident response. They are not a durable/tamper-evident audit ledger; no retention guarantee, restricted search/export API, before/after state model, or administrative review UI exists. `implement-platform-audit-events` must separately define append-only persistence, atomic event/action behavior, actor/action/target/organization/correlation, minimized before/after state, retention, access control, privacy, export, and tamper resistance. diff --git a/docs/security/upload-security-review.md b/docs/security/upload-security-review.md new file mode 100644 index 0000000..1aeb1d2 --- /dev/null +++ b/docs/security/upload-security-review.md @@ -0,0 +1,18 @@ +# Upload Security Review + +| Control | Phase 8 behavior | +| --- | --- | +| Authentication/authorization | coloring/storybook: admin, super admin, teacher, instructor, content admin; badge: admin/super admin | +| Rate limiting | authenticated user key, `upload` policy | +| Size | streamed 5 MiB maximum; partial file removed on failure | +| Format | PNG, JPEG, GIF, WebP only; suffix, claimed MIME, magic structure, dimensions, maximum side (12,000), and maximum pixels (40M) checked | +| SVG/active content | rejected | +| Filename/path | client path is never used for storage; UUID filename plus validated suffix stays in configured directory | +| Completion/overwrite | random name and `.part` atomic replacement; partial cleanup; practical collision resistance | +| Accessibility metadata | binary endpoint returns only file URL; alt text/instructions remain separate lesson/activity data | +| Public access | existing `/storybook`, `/colorings`, `/badges` static paths remain public for compatibility and receive application `nosniff` headers | +| Replace/delete/ownership | no replacement/delete upload endpoint exists; therefore unsupported and disabled | + +The server does not trust extension or MIME alone. Rejections emit metadata-only security events. Tests cover unauthorized roles, oversized bodies, MIME mismatch, signature mismatch, path-like filenames, safe generated names, and rate limiting. + +Residual risks: standard-library structural checks are not full decoding; metadata is not stripped; there is no malware scanning, quarantine, private per-org delivery, retention cleanup, or object-store isolation. Those belong to the future asset-management/storage change, not this phase. diff --git a/docs/ux-overhaul/backend-gap-register.md b/docs/ux-overhaul/backend-gap-register.md index a26ff40..3d57a78 100644 --- a/docs/ux-overhaul/backend-gap-register.md +++ b/docs/ux-overhaul/backend-gap-register.md @@ -4,6 +4,8 @@ Date: 2026-07-13 Phase 7 verification note (2026-07-23): this Phase 6 register remains the historical UI-overhaul record. The evidence-based [backend capability audit](../platform-maturity/backend-capability-audit.md) is authoritative for current classifications and the [future OpenSpec roadmap](../platform-maturity/future-openspec-roadmap.md) owns sequencing. In particular, curriculum CRUD, review state, version history, assets, and reporting were reclassified as partially supported rather than wholly absent; unauthenticated forum mutation remains a confirmed critical security gap. +Phase 8 update (2026-08-06): forum writes now require authenticated server-derived ownership and author/moderator enforcement; public reads remain intentional. Privileged-user response/invariant and cross-organization evidence is canonical under [docs/security](../security/phase-8-security-baseline.md), so the Phase 6 gap rows below remain historical rather than current security claims. + | Role | User need | Current frontend behavior | API limitation | Privacy/security implication | Blocks current workflow | Future OpenSpec change | Priority | | --- | --- | --- | --- | --- | --- | --- | --- | | Teacher/instructor | Scoped learner review | Read-only/scoped UI | No complete class-scoped review/feedback persistence | Avoid global learner disclosure | Yes for persistent feedback | Educator review and feedback contract | High | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9a47233..9cf803c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,21 +8,21 @@ "name": "echoed-frontend", "version": "0.0.0", "dependencies": { - "@angular/animations": "20.3.25", + "@angular/animations": "20.3.27", "@angular/cdk": "^20.2.2", - "@angular/common": "20.3.25", - "@angular/compiler": "20.3.25", - "@angular/core": "20.3.25", - "@angular/forms": "20.3.25", - "@angular/platform-browser": "20.3.25", - "@angular/platform-browser-dynamic": "20.3.25", - "@angular/router": "20.3.25", + "@angular/common": "20.3.27", + "@angular/compiler": "20.3.27", + "@angular/core": "20.3.27", + "@angular/forms": "20.3.27", + "@angular/platform-browser": "20.3.27", + "@angular/platform-browser-dynamic": "20.3.27", + "@angular/router": "20.3.27", "zone.js": "~0.15.1" }, "devDependencies": { "@angular-devkit/build-angular": "^20.2.2", "@angular/cli": "^20.2.2", - "@angular/compiler-cli": "20.3.25", + "@angular/compiler-cli": "20.3.27", "@playwright/test": "^1.54.2", "@types/jasmine": "~5.1.0", "autoprefixer": "^10.4.21", @@ -426,708 +426,6 @@ } } }, - "node_modules/@angular-devkit/build-angular/node_modules/@angular/build": { - "version": "20.3.26", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-20.3.26.tgz", - "integrity": "sha512-WXpntiPE3YWprEvam7S2KJd8u/FwAYB5aZqeVniOoa4aru6a33waN8f1oWTM0DeNsvvjl3qI7UpEjbyjZ7QYmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2003.26", - "@babel/core": "7.28.3", - "@babel/helper-annotate-as-pure": "7.27.3", - "@babel/helper-split-export-declaration": "7.24.7", - "@inquirer/confirm": "5.1.14", - "@vitejs/plugin-basic-ssl": "2.1.0", - "beasties": "0.3.5", - "browserslist": "^4.23.0", - "esbuild": "0.28.0", - "https-proxy-agent": "7.0.6", - "istanbul-lib-instrument": "6.0.3", - "jsonc-parser": "3.3.1", - "listr2": "9.0.1", - "magic-string": "0.30.17", - "mrmime": "2.0.1", - "parse5-html-rewriting-stream": "8.0.0", - "picomatch": "4.0.4", - "piscina": "5.1.3", - "rollup": "4.59.0", - "sass": "1.90.0", - "semver": "7.7.2", - "source-map-support": "0.5.21", - "tinyglobby": "0.2.14", - "vite": "7.3.2", - "watchpack": "2.4.4" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "optionalDependencies": { - "lmdb": "3.4.2" - }, - "peerDependencies": { - "@angular/compiler": "^20.0.0", - "@angular/compiler-cli": "^20.0.0", - "@angular/core": "^20.0.0", - "@angular/localize": "^20.0.0", - "@angular/platform-browser": "^20.0.0", - "@angular/platform-server": "^20.0.0", - "@angular/service-worker": "^20.0.0", - "@angular/ssr": "^20.3.26", - "karma": "^6.4.0", - "less": "^4.2.0", - "ng-packagr": "^20.0.0", - "postcss": "^8.4.0", - "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "tslib": "^2.3.0", - "typescript": ">=5.8 <6.0", - "vitest": "^3.1.1" - }, - "peerDependenciesMeta": { - "@angular/core": { - "optional": true - }, - "@angular/localize": { - "optional": true - }, - "@angular/platform-browser": { - "optional": true - }, - "@angular/platform-server": { - "optional": true - }, - "@angular/service-worker": { - "optional": true - }, - "@angular/ssr": { - "optional": true - }, - "karma": { - "optional": true - }, - "less": { - "optional": true - }, - "ng-packagr": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tailwindcss": { - "optional": true - }, - "vitest": { - "optional": true - } - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@angular/build/node_modules/@vitejs/plugin-basic-ssl": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz", - "integrity": "sha512-dOxxrhgyDIEUADhb/8OlV9JIqYLgos03YorAueTIeOUskLJSEsfwCByjbu98ctXitUN3znXKp0bYD/WHSudCeA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "peerDependencies": { - "vite": "^6.0.0 || ^7.0.0" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@angular/build/node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@angular/build/node_modules/vite/node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@angular/build/node_modules/vite/node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@angular/build/node_modules/watchpack": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", - "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@angular-devkit/build-angular/node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -1216,21 +514,6 @@ "url": "https://github.com/sponsors/rawify" } }, - "node_modules/@angular-devkit/build-angular/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/@angular-devkit/build-angular/node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -1483,9 +766,9 @@ } }, "node_modules/@angular/animations": { - "version": "20.3.25", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.25.tgz", - "integrity": "sha512-lQmti3tI85D525TjUVGqCNLzFxSUoZg+vgIyvuGJZPY0UU/o2S6KAxW6ObmcRotZZHNfenLHIxWgzamBDjIjuw==", + "version": "20.3.27", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.27.tgz", + "integrity": "sha512-BgGTloDiD3qIFVSxZq8xO6CiyhKn00WbhQQiklZF8WI2hXd3Hmc1OUAAHqSMh2c9uL7X1ZYkg9lSzjiasK2vKg==", "deprecated": "@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.", "license": "MIT", "dependencies": { @@ -1495,7 +778,127 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.3.25" + "@angular/core": "20.3.27" + } + }, + "node_modules/@angular/build": { + "version": "20.3.26", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-20.3.26.tgz", + "integrity": "sha512-WXpntiPE3YWprEvam7S2KJd8u/FwAYB5aZqeVniOoa4aru6a33waN8f1oWTM0DeNsvvjl3qI7UpEjbyjZ7QYmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "0.2003.26", + "@babel/core": "7.28.3", + "@babel/helper-annotate-as-pure": "7.27.3", + "@babel/helper-split-export-declaration": "7.24.7", + "@inquirer/confirm": "5.1.14", + "@vitejs/plugin-basic-ssl": "2.1.0", + "beasties": "0.3.5", + "browserslist": "^4.23.0", + "esbuild": "0.28.0", + "https-proxy-agent": "7.0.6", + "istanbul-lib-instrument": "6.0.3", + "jsonc-parser": "3.3.1", + "listr2": "9.0.1", + "magic-string": "0.30.17", + "mrmime": "2.0.1", + "parse5-html-rewriting-stream": "8.0.0", + "picomatch": "4.0.4", + "piscina": "5.1.3", + "rollup": "4.59.0", + "sass": "1.90.0", + "semver": "7.7.2", + "source-map-support": "0.5.21", + "tinyglobby": "0.2.14", + "vite": "7.3.2", + "watchpack": "2.4.4" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "optionalDependencies": { + "lmdb": "3.4.2" + }, + "peerDependencies": { + "@angular/compiler": "^20.0.0", + "@angular/compiler-cli": "^20.0.0", + "@angular/core": "^20.0.0", + "@angular/localize": "^20.0.0", + "@angular/platform-browser": "^20.0.0", + "@angular/platform-server": "^20.0.0", + "@angular/service-worker": "^20.0.0", + "@angular/ssr": "^20.3.26", + "karma": "^6.4.0", + "less": "^4.2.0", + "ng-packagr": "^20.0.0", + "postcss": "^8.4.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "tslib": "^2.3.0", + "typescript": ">=5.8 <6.0", + "vitest": "^3.1.1" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "@angular/localize": { + "optional": true + }, + "@angular/platform-browser": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "@angular/ssr": { + "optional": true + }, + "karma": { + "optional": true + }, + "less": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tailwindcss": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@angular/build/node_modules/sass": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.90.0.tgz", + "integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" } }, "node_modules/@angular/cdk": { @@ -1549,9 +952,9 @@ } }, "node_modules/@angular/common": { - "version": "20.3.25", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.25.tgz", - "integrity": "sha512-rnRGcXbjet0DHgkRL4Dqxk21G2T4UypVfiTV/fay58H8w9U89PJ1L6gRmk8B/uyfpii/9r23cBwnpcguQykxYw==", + "version": "20.3.27", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.27.tgz", + "integrity": "sha512-4ectYP60XatB9zZ40WlfmaTzjmEhaz8SSqLsbZI4VZ8gDb5qNmxWtwwt8UxS3NmDHEgqdNL8UPO4E94+yKCICg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1560,14 +963,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.3.25", + "@angular/core": "20.3.27", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "20.3.25", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.25.tgz", - "integrity": "sha512-TSh6gVoQqlLPqWwsYMK0lfVEQYENQO+USzS+BHFXEHFfgBRap6qDpIUGnRdj0Y2PlaVJUVFbeq1855EZUPUEoA==", + "version": "20.3.27", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.27.tgz", + "integrity": "sha512-in3THZ678GAYuOR9RZV18+zZz0KGhlGikyUEfLeALLjGf9ZaR3n+t19BmYx6G2VkF/Xqadne1omQ2vbl6PRASA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1577,13 +980,13 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "20.3.25", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.25.tgz", - "integrity": "sha512-iqxwVo5Pgzt3EfT49OZ6plxA6KKxwv7ixx1XNH7QRvaOJC9gmsPScWpx+LO7ZsVZdo/NkA+rnXDl0PauUgGciw==", + "version": "20.3.27", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.27.tgz", + "integrity": "sha512-R0j9mFfUdGmmw867V/TfMSOBkLZT6ASxyY5tc1NDNmxQioZDVIDP9pqBOayzhJ0xiuDc9JellQXUTZ+vm+b/Zg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "7.28.3", + "@babel/core": "7.29.7", "@jridgewell/sourcemap-codec": "^1.4.14", "chokidar": "^4.0.0", "convert-source-map": "^1.5.1", @@ -1600,7 +1003,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.25", + "@angular/compiler": "20.3.27", "typescript": ">=5.8 <6.0" }, "peerDependenciesMeta": { @@ -1609,10 +1012,75 @@ } } }, + "node_modules/@angular/compiler-cli/node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "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/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.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@angular/compiler-cli/node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@angular/core": { - "version": "20.3.25", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.25.tgz", - "integrity": "sha512-B4XnnR5jzikZDvZ4PjwjAWZMT14dxrKrmJdwa/n0yp7rMPkIJTKF6ZJMg4d1pLWLLSsc2oWHioN3UrWlGqIKnA==", + "version": "20.3.27", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.27.tgz", + "integrity": "sha512-8EfYIUST5CKldOF4MAYWTFFRB7EtwqUoQBZdar6US39/EEzWm/wm/iRNkH2jmKe4YuPa2GoeHS1WQ8VRuOk7Dg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1621,7 +1089,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.25", + "@angular/compiler": "20.3.27", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0" }, @@ -1635,9 +1103,9 @@ } }, "node_modules/@angular/forms": { - "version": "20.3.25", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.25.tgz", - "integrity": "sha512-vGRo1LVPFo2Cu0k+QyDTlsBv5UbN0c3Et2YMS+43oyi1c4keocntBccOjLyM5C0kpMz4+pP81MqqYpAWu2k+TQ==", + "version": "20.3.27", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.27.tgz", + "integrity": "sha512-cNG26wi3tr3m8At6puxJpAMVk9mBhEqREA4Jk/klalMwWuYEf8ApTEAw0a5NUfMxDkL3dcJJwDRf8rK6ma6TYQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1646,16 +1114,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.25", - "@angular/core": "20.3.25", - "@angular/platform-browser": "20.3.25", + "@angular/common": "20.3.27", + "@angular/core": "20.3.27", + "@angular/platform-browser": "20.3.27", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/platform-browser": { - "version": "20.3.25", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.25.tgz", - "integrity": "sha512-0k06U/AJRQifGMLkcU3R9uEHWbuKEzkKMuKcGagXTrkeFvCG2Ub4JdsbcjFNWB2bspWgaxIMSceuj7c83U5wOA==", + "version": "20.3.27", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.27.tgz", + "integrity": "sha512-IV2zQ4zk6liyw5NE48bQqSk3nOAZ1rmDAQi7W4Kw0N8cs9MYVgK3zulo0zx5UqSv1kuNDAGb0HPR5tUGVOf9kw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1664,9 +1132,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "20.3.25", - "@angular/common": "20.3.25", - "@angular/core": "20.3.25" + "@angular/animations": "20.3.27", + "@angular/common": "20.3.27", + "@angular/core": "20.3.27" }, "peerDependenciesMeta": { "@angular/animations": { @@ -1675,9 +1143,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "20.3.25", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.25.tgz", - "integrity": "sha512-3Ku+IsN4tQPVBsw75SoLbLf7TsXAGL0rGPHSsyNYFhG2ZZeQuYNIAi8mc4cwz/qMDnuassHFrCxuLDgN6Yab5w==", + "version": "20.3.27", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.27.tgz", + "integrity": "sha512-4UUs8vOswgBOWCRoeZrswguarBLrM3j6WGb927Y3GXdN37fVJQYjyKHivNeQwZvwsGgl5Nl6mvpBpZv47n/OrQ==", "deprecated": "@angular/platform-browser-dynamic is deprecated. Use `@angular/platform-browser` instead.", "license": "MIT", "dependencies": { @@ -1687,16 +1155,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.25", - "@angular/compiler": "20.3.25", - "@angular/core": "20.3.25", - "@angular/platform-browser": "20.3.25" + "@angular/common": "20.3.27", + "@angular/compiler": "20.3.27", + "@angular/core": "20.3.27", + "@angular/platform-browser": "20.3.27" } }, "node_modules/@angular/router": { - "version": "20.3.25", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.25.tgz", - "integrity": "sha512-YIjLHWAufTaukNj15hEoys29e7XNhnCRsS1/95h/OqR69R3adbB8hV7ut7gO6XdXokriYqb4gtoUjoESxR+xFQ==", + "version": "20.3.27", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.27.tgz", + "integrity": "sha512-F3hfJQ0GAuD6LdeB7A6fMfaErc4HXCtCQGh3C/8VrbVEbGfIgSveNjQXzGYPNklnOLhj1BmWf5w0WniUAEjLBA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1705,20 +1173,20 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.25", - "@angular/core": "20.3.25", - "@angular/platform-browser": "20.3.25", + "@angular/common": "20.3.27", + "@angular/core": "20.3.27", + "@angular/platform-browser": "20.3.27", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -1727,9 +1195,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -1815,14 +1283,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -1941,9 +1409,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -1965,29 +1433,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2083,9 +1551,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -2093,9 +1561,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -2103,9 +1571,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -2128,27 +1596,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -3311,33 +2779,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -3345,14 +2813,14 @@ } }, "node_modules/@babel/traverse/node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -3362,14 +2830,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -4244,6 +3712,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -6831,6 +6310,19 @@ "@types/node": "*" } }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz", + "integrity": "sha512-dOxxrhgyDIEUADhb/8OlV9JIqYLgos03YorAueTIeOUskLJSEsfwCByjbu98ctXitUN3znXKp0bYD/WHSudCeA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -7531,28 +7023,235 @@ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "20.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", + "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/cacache/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", "dev": true, "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, "engines": { - "node": ">=8" + "node": ">= 6" } }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", "dev": true, "funding": [ { @@ -7561,43 +7260,79 @@ }, { "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" }, { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "readdirp": "^4.0.1" }, - "bin": { - "browserslist": "cli.js" + "engines": { + "node": ">= 14.16.0" }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": ">=18" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=6.0" + } }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "dev": true, "license": "MIT", "dependencies": { - "run-applescript": "^7.0.0" + "restore-cursor": "^5.0.0" }, "engines": { "node": ">=18" @@ -7606,852 +7341,968 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cacache": { - "version": "20.0.4", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", - "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "@npmcli/fs": "^5.0.0", - "fs-minipass": "^3.0.0", - "glob": "^13.0.0", - "lru-cache": "^11.1.0", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^13.0.0" + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cacache/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, - "license": "MIT", + "license": "ISC", "engines": { - "node": "18 || 20 || >=22" + "node": ">= 12" } }, - "node_modules/cacache/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "balanced-match": "^4.0.2" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=20" } }, - "node_modules/cacache/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, "engines": { - "node": "20 || >=22" + "node": ">=6" } }, - "node_modules/cacache/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "node_modules/clone-deep/node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "brace-expansion": "^5.0.5" + "isobject": "^3.0.1" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": ">=7.0.0" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", "dev": true, "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, "engines": { - "node": ">= 0.4" + "node": ">= 6" } }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" + "mime-db": ">= 1.43.0 < 2" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.6" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", "dev": true, "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, "engines": { - "node": ">=6" + "node": ">= 0.8.0" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 6" + "dependencies": { + "ms": "2.0.0" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" + "license": "MIT" }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", "dev": true, "license": "MIT", "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">= 0.6" } }, - "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, "license": "MIT" }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", "dev": true, "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" }, "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": ">= 0.10.0" } }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=0.8" } }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.0" + "node": ">= 0.8" } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "node_modules/connect/node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", "dev": true, "license": "MIT", "dependencies": { - "restore-cursor": "^5.0.0" + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" }, "engines": { - "node": ">=18" + "node": ">= 0.8" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/connect/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 0.8" } }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "node_modules/connect/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.6" } }, - "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "dev": true, "license": "MIT", - "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, "engines": { "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "dev": true, - "license": "ISC", + "license": "MIT", "engines": { - "node": ">= 12" + "node": ">= 0.6" } }, - "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=20" + "node": ">= 0.6" } }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=6.6.0" } }, - "node_modules/clone-deep": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", - "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "node_modules/copy-anything": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", + "integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==", "dev": true, "license": "MIT", "dependencies": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" + "is-what": "^3.14.1" }, - "engines": { - "node": ">=6" + "funding": { + "url": "https://github.com/sponsors/mesqueeb" } }, - "node_modules/clone-deep/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "node_modules/copy-webpack-plugin": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz", + "integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==", "dev": true, "license": "MIT", "dependencies": { - "isobject": "^3.0.1" + "glob-parent": "^6.0.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^7.0.3", + "tinyglobby": "^0.2.12" }, "engines": { - "node": ">=0.10.0" + "node": ">= 20.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "browserslist": "^4.28.1" }, - "engines": { - "node": ">=7.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true, "license": "MIT" }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "dev": true, "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, "engines": { - "node": ">= 6" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { - "mime-db": ">= 1.43.0 < 2" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": ">= 0.6" + "node": ">= 8" } }, - "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "node_modules/css-loader": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", + "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", "dev": true, "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" + "icss-utils": "^5.1.0", + "postcss": "^8.4.40", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.6.3" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } } }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/css-select": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "ms": "2.0.0" + "boolbase": "^1.0.0", + "css-what": "^7.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "nth-check": "^2.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/compression/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "node_modules/css-what": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", "dev": true, - "license": "MIT" + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } }, - "node_modules/compression/node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true, "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, "engines": { - "node": ">= 0.6" + "node": ">=4" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "node_modules/custom-event": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", + "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", "dev": true, "license": "MIT" }, - "node_modules/connect": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", - "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "node_modules/date-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", + "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", "dev": true, "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "finalhandler": "1.1.2", - "parseurl": "~1.3.3", - "utils-merge": "1.0.1" - }, "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/connect-history-api-fallback": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", - "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "node": ">=4.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, "engines": { - "node": ">=0.8" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/connect/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", "dev": true, "license": "MIT", "dependencies": { - "ms": "2.0.0" + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/connect/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/connect/node_modules/finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", "dev": true, "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "statuses": "~1.5.0", - "unpipe": "~1.0.0" - }, "engines": { - "node": ">= 0.8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/connect/node_modules/ms": { + "node_modules/depd": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/connect/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "dev": true, "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, "engines": { "node": ">= 0.8" } }, - "node_modules/connect/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=8" } }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "license": "MIT" }, - "node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "node_modules/di": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", + "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", "dev": true, "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, "engines": { - "node": ">= 0.6" + "node": ">=6" } }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "node_modules/dom-serialize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", + "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6.6.0" + "dependencies": { + "custom-event": "~1.0.0", + "ent": "~2.2.0", + "extend": "^3.0.0", + "void-elements": "^2.0.0" } }, - "node_modules/copy-anything": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", - "integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==", + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "dev": true, "license": "MIT", "dependencies": { - "is-what": "^3.14.1" + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" }, "funding": { - "url": "https://github.com/sponsors/mesqueeb" + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, - "node_modules/copy-webpack-plugin": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz", - "integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==", + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "dev": true, - "license": "MIT", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "glob-parent": "^6.0.1", - "normalize-path": "^3.0.0", - "schema-utils": "^4.2.0", - "serialize-javascript": "^7.0.3", - "tinyglobby": "^0.2.12" + "domelementtype": "^2.3.0" }, "engines": { - "node": ">= 20.9.0" + "node": ">= 4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" }, - "peerDependencies": { - "webpack": "^5.1.0" + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/core-js-compat": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", - "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.28.1" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" + "engines": { + "node": ">= 0.4" } }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "dev": true, "license": "MIT" }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "node_modules/electron-to-chromium": { + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", "dev": true, "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 4" } }, - "node_modules/cosmiconfig": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", - "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "dev": true, "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "node": ">= 0.8" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/engine.io": { + "version": "6.6.8", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz", + "integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==", "dev": true, "license": "MIT", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.20.1" }, "engines": { - "node": ">= 8" + "node": ">=10.2.0" } }, - "node_modules/css-loader": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", - "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", "dev": true, "license": "MIT", - "dependencies": { - "icss-utils": "^5.1.0", - "postcss": "^8.4.40", - "postcss-modules-extract-imports": "^3.1.0", - "postcss-modules-local-by-default": "^4.0.5", - "postcss-modules-scope": "^3.2.0", - "postcss-modules-values": "^4.0.0", - "postcss-value-parser": "^4.2.0", - "semver": "^7.6.3" - }, "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", - "webpack": "^5.27.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } + "node": ">=10.0.0" } }, - "node_modules/css-select": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", - "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^7.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "nth-check": "^2.1.1" + "mime-types": "~2.1.34", + "negotiator": "0.6.3" }, - "funding": { - "url": "https://github.com/sponsors/fb55" + "engines": { + "node": ">= 0.6" } }, - "node_modules/css-what": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", - "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" + "node": ">= 0.6" } }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" + "dependencies": { + "mime-db": "1.52.0" }, "engines": { - "node": ">=4" + "node": ">= 0.6" } }, - "node_modules/custom-event": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", - "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", - "dev": true, - "license": "MIT" - }, - "node_modules/date-format": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", - "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "dev": true, "license": "MIT", "engines": { - "node": ">=4.0" + "node": ">= 0.6" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/enhanced-resolve": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", + "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=10.13.0" } }, - "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "node_modules/ent": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", + "integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==", "dev": true, "license": "MIT", "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "punycode": "^1.4.1", + "safe-regex-test": "^1.1.0" }, "engines": { - "node": ">=18" + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", "dev": true, "license": "MIT", "engines": { @@ -8461,3637 +8312,3730 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" + "optional": true, + "dependencies": { + "prr": "~1.0.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "errno": "cli.js" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.8" + "dependencies": { + "is-arrayish": "^0.2.1" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">= 0.4" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, - "license": "Apache-2.0", - "optional": true, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.4" } }, - "node_modules/detect-node": { + "node_modules/es-module-lexer": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "license": "MIT" - }, - "node_modules/di": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", - "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", - "dev": true, - "license": "MIT" - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, - "node_modules/dns-packet": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.1" + "es-errors": "^1.3.0" }, "engines": { - "node": ">=6" - } - }, - "node_modules/dom-serialize": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", - "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "custom-event": "~1.0.0", - "ent": "~2.2.0", - "extend": "^3.0.0", - "void-elements": "^2.0.0" + "node": ">= 0.4" } }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": ">= 4" + "node": ">=18" }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "node_modules/esbuild-wasm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.28.0.tgz", + "integrity": "sha512-5TRVKExcEmeMkccIZMzUq+Az6X2RoMAJyfl6SMMO1dMVhmvt0I2mx7gAb6zYi42n4d1ETcatFXazGKzA+aW7fg==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" + "engines": { + "node": ">=18" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, "engines": { - "node": ">= 0.4" + "node": ">=6" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.361", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", - "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "dev": true, "license": "MIT" }, - "node_modules/emojis-list": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, "engines": { - "node": ">= 0.8" + "node": ">=8.0.0" } }, - "node_modules/engine.io": { - "version": "6.6.8", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz", - "integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==", + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "@types/cors": "^2.8.12", - "@types/node": ">=10.0.0", - "@types/ws": "^8.5.12", - "accepts": "~1.3.4", - "base64id": "2.0.0", - "cookie": "~0.7.2", - "cors": "~2.8.5", - "debug": "~4.4.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.20.1" + "estraverse": "^5.2.0" }, "engines": { - "node": ">=10.2.0" + "node": ">=4.0" } }, - "node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "engines": { - "node": ">=10.0.0" + "node": ">=4.0" } }, - "node_modules/engine.io/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, + "license": "BSD-2-Clause", "engines": { - "node": ">= 0.6" + "node": ">=4.0" } }, - "node_modules/engine.io/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "engines": { - "node": ">= 0.6" + "node": ">=0.10.0" } }, - "node_modules/engine.io/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "dev": true, "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, "engines": { "node": ">= 0.6" } }, - "node_modules/engine.io/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=0.8.x" } }, - "node_modules/enhanced-resolve": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", - "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" + "eventsource-parser": "^3.0.1" }, "engines": { - "node": ">=10.13.0" + "node": ">=18.0.0" } }, - "node_modules/ent": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", - "integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==", + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "punycode": "^1.4.1", - "safe-regex-test": "^1.1.0" - }, "engines": { - "node": ">= 0.4" + "node": ">=18.0.0" } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } + "license": "Apache-2.0" }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.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.0", + "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.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, "engines": { - "node": ">=6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "dev": true, "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, "engines": { - "node": ">=18" + "node": ">= 16" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" } }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "dev": true, "license": "MIT" }, - "node_modules/errno": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", - "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "prr": "~1.0.1" + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" }, - "bin": { - "errno": "cli.js" + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" } }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, - "license": "MIT", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", "dependencies": { - "is-arrayish": "^0.2.1" + "reusify": "^1.0.4" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, "engines": { - "node": ">= 0.4" + "node": ">=0.8.0" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0" + "to-regex-range": "^5.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">=8" } }, - "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "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" }, "engines": { - "node": ">=18" + "node": ">= 18.0.0" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/esbuild-wasm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.28.0.tgz", - "integrity": "sha512-5TRVKExcEmeMkccIZMzUq+Az6X2RoMAJyfl6SMMO1dMVhmvt0I2mx7gAb6zYi42n4d1ETcatFXazGKzA+aW7fg==", + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": ">=18" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, - "license": "MIT" + "license": "ISC" }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", "engines": { - "node": ">=8.0.0" + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, + "license": "MIT", "engines": { - "node": ">=4.0" + "node": ">= 0.6" } }, - "node_modules/esrecurse/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "engines": { - "node": ">=4.0" + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" } }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "engines": { - "node": ">=4.0" + "node": ">= 0.8" } }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=6 <7 || >=8" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, "engines": { - "node": ">= 0.6" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true, - "license": "MIT" + "license": "ISC" }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, + "hasInstallScript": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=0.8.x" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eventsource-parser": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", - "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=6.9.0" } }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, - "license": "Apache-2.0" + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.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.0", - "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.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, "engines": { - "node": ">= 18" + "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/express-rate-limit": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", - "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { - "ip-address": "^10.2.0" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { - "node": ">= 16" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">=8.6.0" + "node": ">= 0.4" } }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { - "is-glob": "^4.0.1" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" }, "engines": { - "node": ">= 6" + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { - "reusify": "^1.0.4" + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" } }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "node_modules/glob-to-regex.js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", + "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", "dev": true, "license": "Apache-2.0", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, "engines": { - "node": ">=0.8.0" + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, "engines": { "node": ">=8" } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", - "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" - }, "engines": { - "node": ">= 18.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "has-symbols": "^1.0.3" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "dev": true, - "license": "BSD-3-Clause", - "bin": { - "flat": "cli.js" + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "node_modules/hono": { + "version": "4.12.22", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.22.tgz", + "integrity": "sha512-7fvVPbB92zNRsQke+uiRGwtTuef0tB2Dg4hWxYfFNvkQhIltWoyi0ONReM5LWA+jJWS3nfT5lTq+qbsIpX0IQw==", "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], "license": "MIT", "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } + "node": ">=16.9.0" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, "engines": { - "node": ">= 0.6" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" + "node": "20 || >=22" } }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.8" + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" } }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" } }, - "node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "node_modules/hpack.js/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "ISC", + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "safe-buffer": "~5.1.0" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, - "license": "ISC" + "license": "MIT" }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } + "license": "BSD-2-Clause" }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } + "license": "MIT" }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "dev": true, "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, "engines": { - "node": ">=18" + "node": ">= 0.8" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8.0.0" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">= 0.4" + "node": ">= 14" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "node_modules/http-proxy-middleware": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", + "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "@types/http-proxy": "^1.17.15", + "debug": "^4.3.6", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.3", + "is-plain-object": "^5.0.0", + "micromatch": "^4.0.8" }, "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.3" + "agent-base": "^7.1.2", + "debug": "4" }, "engines": { - "node": ">=10.13.0" + "node": ">= 14" } }, - "node_modules/glob-to-regex.js": { + "node_modules/hyperdyperid": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", - "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" + "node": ">=10.18" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "dev": true, "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, "engines": { - "node": ">= 0.4" + "node": ">=0.10.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", "dev": true, - "license": "ISC" + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } }, - "node_modules/handle-thing": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", - "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "node_modules/ignore-walk": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", + "integrity": "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==", "dev": true, - "license": "MIT" + "license": "ISC", + "dependencies": { + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/ignore-walk/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/ignore-walk/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "balanced-match": "^4.0.2" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "18 || 20 || >=22" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "node_modules/ignore-walk/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "has-symbols": "^1.0.3" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">= 0.4" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "node_modules/image-size": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", + "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", "dev": true, "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" + "optional": true, + "bin": { + "image-size": "bin/image-size.js" }, "engines": { - "node": ">= 0.4" + "node": ">=0.10.0" } }, - "node_modules/hono": { - "version": "4.12.22", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.22.tgz", - "integrity": "sha512-7fvVPbB92zNRsQke+uiRGwtTuef0tB2Dg4hWxYfFNvkQhIltWoyi0ONReM5LWA+jJWS3nfT5lTq+qbsIpX0IQw==", + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, "engines": { - "node": ">=16.9.0" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/hosted-git-info": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", - "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, "license": "ISC", "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" + "once": "^1.3.0", + "wrappy": "1" } }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "ISC" + }, + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "dev": true, + "license": "ISC", "engines": { - "node": "20 || >=22" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/hpack.js": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "dev": true, "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "obuf": "^1.0.0", - "readable-stream": "^2.0.1", - "wbuf": "^1.1.0" + "engines": { + "node": ">= 12" } }, - "node_modules/hpack.js/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "dev": true, "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "engines": { + "node": ">= 0.10" } }, - "node_modules/hpack.js/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true, "license": "MIT" }, - "node_modules/hpack.js/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, "license": "MIT", "dependencies": { - "safe-buffer": "~5.1.0" + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/htmlparser2": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", - "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], "license": "MIT", "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "entities": "^7.0.1" - } - }, - "node_modules/htmlparser2/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", + "hasown": "^2.0.3" + }, "engines": { - "node": ">=0.12" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/http-deceiver": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", "dev": true, "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" + "bin": { + "is-docker": "cli.js" }, "engines": { - "node": ">= 0.8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/http-parser-js": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, "engines": { - "node": ">=8.0.0" + "node": ">=0.10.0" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", "dev": true, "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, "engines": { - "node": ">= 14" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/http-proxy-middleware": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", - "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { - "@types/http-proxy": "^1.17.15", - "debug": "^4.3.6", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.3", - "is-plain-object": "^5.0.0", - "micromatch": "^4.0.8" + "is-extglob": "^2.1.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=0.10.0" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" }, "engines": { - "node": ">= 14" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/hyperdyperid": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", - "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=10.18" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "node_modules/is-network-error": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", "dev": true, "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, "engines": { - "node": ">=0.10.0" + "node": ">=16" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/icss-utils": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", - "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, - "license": "ISC", + "license": "MIT", "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" + "node": ">=0.12.0" } }, - "node_modules/ignore-walk": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", - "integrity": "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==", + "node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", "dev": true, - "license": "ISC", - "dependencies": { - "minimatch": "^10.0.3" - }, + "license": "MIT", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ignore-walk/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", "dev": true, "license": "MIT", "engines": { - "node": "18 || 20 || >=22" + "node": ">=0.10.0" } }, - "node_modules/ignore-walk/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ignore-walk/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, + "license": "MIT", "engines": { - "node": "18 || 20 || >=22" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/image-size": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", - "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", + "node_modules/is-what": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz", + "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "dev": true, "license": "MIT", - "optional": true, - "bin": { - "image-size": "bin/image-size.js" + "dependencies": { + "is-inside-container": "^1.0.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/immutable": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", - "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, "license": "MIT" }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", "dev": true, "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, "engines": { - "node": ">=6" + "node": ">= 8.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/gjtorikian/" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, - "license": "ISC", + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, - "license": "ISC" + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } }, - "node_modules/ini": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", - "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", "dev": true, - "license": "ISC", + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": ">=10" } }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "engines": { - "node": ">= 12" + "node": ">=0.10.0" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, "engines": { - "node": ">= 0.10" + "node": ">=8" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "node_modules/jasmine-core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.2.0.tgz", + "integrity": "sha512-tSAtdrvWybZkQmmaIoDgnvHG8ORUNw5kEVlO5CvrXj02Jjr9TZrmjFq7FUiOUzJiOP2wLGYT6PgrQgQF4R1xiw==", "dev": true, "license": "MIT" }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, "license": "MIT", "dependencies": { - "binary-extensions": "^2.0.0" + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" }, "engines": { - "node": ">=8" + "node": ">= 10.13.0" } }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.3" + "has-flag": "^4.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "jiti": "bin/jiti.js" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=0.10.0" + "funding": { + "url": "https://github.com/sponsors/panva" } }, - "node_modules/is-fullwidth-code-point": { + "node_modules/js-tokens": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "argparse": "^2.0.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" + "bin": { + "jsesc": "bin/jsesc" }, "engines": { - "node": ">=0.10.0" + "node": ">=6" } }, - "node_modules/is-inside-container": { + "node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", + "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, "bin": { - "is-inside-container": "cli.js" + "json5": "lib/cli.js" }, "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6" } }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, - "node_modules/is-network-error": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", - "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/karma": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", + "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, "license": "MIT", - "engines": { - "node": ">=16" + "dependencies": { + "@colors/colors": "1.5.0", + "body-parser": "^1.19.0", + "braces": "^3.0.2", + "chokidar": "^3.5.1", + "connect": "^3.7.0", + "di": "^0.0.1", + "dom-serialize": "^2.2.1", + "glob": "^7.1.7", + "graceful-fs": "^4.2.6", + "http-proxy": "^1.18.1", + "isbinaryfile": "^4.0.8", + "lodash": "^4.17.21", + "log4js": "^6.4.1", + "mime": "^2.5.2", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.5", + "qjobs": "^1.2.0", + "range-parser": "^1.2.1", + "rimraf": "^3.0.2", + "socket.io": "^4.7.2", + "source-map": "^0.6.1", + "tmp": "^0.2.1", + "ua-parser-js": "^0.7.30", + "yargs": "^16.1.1" + }, + "bin": { + "karma": "bin/karma" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 10" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/karma-chrome-launcher": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz", + "integrity": "sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==", "dev": true, "license": "MIT", - "engines": { - "node": ">=0.12.0" + "dependencies": { + "which": "^1.2.1" } }, - "node_modules/is-plain-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", - "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "node_modules/karma-chrome-launcher/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "which": "bin/which" } }, - "node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "node_modules/karma-coverage": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz", + "integrity": "sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==", "dev": true, "license": "MIT", + "dependencies": { + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-instrument": "^5.1.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.1", + "istanbul-reports": "^3.0.5", + "minimatch": "^3.0.4" + }, "engines": { - "node": ">=0.10.0" + "node": ">=10.0.0" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "node_modules/karma-coverage/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "node_modules/karma-coverage/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, - "node_modules/is-what": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz", - "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "node_modules/karma-jasmine": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz", + "integrity": "sha512-i/zQLFrfEpRyQoJF9fsCdTMOF5c2dK7C7OmsuKg2D0YSsuZSfQDiLuaiktbuio6F2wiCsZSnSnieIQ0ant/uzQ==", "dev": true, "license": "MIT", "dependencies": { - "is-inside-container": "^1.0.0" + "jasmine-core": "^4.1.0" }, "engines": { - "node": ">=16" + "node": ">=12" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "karma": "^6.0.0" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "node_modules/karma-jasmine-html-reporter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-2.1.0.tgz", + "integrity": "sha512-sPQE1+nlsn6Hwb5t+HHwyy0A1FNCVKuL1192b+XNauMYWThz2kweiBVW1DqloRpVvZIJkIoHVB7XRpK78n1xbQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 8.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" + "peerDependencies": { + "jasmine-core": "^4.0.0 || ^5.0.0", + "karma": "^6.0.0", + "karma-jasmine": "^5.0.0" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "node_modules/karma-jasmine/node_modules/jasmine-core": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.6.1.tgz", + "integrity": "sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==", "dev": true, - "license": "ISC" + "license": "MIT" }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "node_modules/karma-source-map-support": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", + "integrity": "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A==", "dev": true, "license": "MIT", - "engines": { - "node": ">=0.10.0" + "dependencies": { + "source-map-support": "^0.5.5" } }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "node_modules/karma/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "node_modules/karma/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" + "color-convert": "^2.0.1" }, "engines": { - "node": ">=10" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "node_modules/karma/node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" }, "engines": { - "node": ">=10" + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "node_modules/karma/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" }, "engines": { - "node": ">=10" + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" } }, - "node_modules/istanbul-lib-source-maps/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/karma/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" } }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "node_modules/karma/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" + "ms": "2.0.0" } }, - "node_modules/jasmine-core": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.2.0.tgz", - "integrity": "sha512-tSAtdrvWybZkQmmaIoDgnvHG8ORUNw5kEVlO5CvrXj02Jjr9TZrmjFq7FUiOUzJiOP2wLGYT6PgrQgQF4R1xiw==", + "node_modules/karma/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, - "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "node_modules/karma/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "is-glob": "^4.0.1" }, "engines": { - "node": ">= 10.13.0" + "node": ">= 6" } }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/karma/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "safer-buffer": ">= 2.1.2 < 3" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" + "node": ">=0.10.0" } }, - "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "node_modules/karma/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" + "engines": { + "node": ">=8" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "node_modules/karma/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "dev": true, "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">= 0.6" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/karma/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, "engines": { - "node": ">=6" + "node": ">= 0.6" } }, - "node_modules/json-parse-even-better-errors": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", - "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", + "node_modules/karma/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">= 0.6" } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "node_modules/karma/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true, "license": "MIT" }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "node_modules/karma/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, - "license": "BSD-2-Clause" + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/karma/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { - "node": ">=6" + "node": ">= 0.8" } }, - "node_modules/jsonc-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "node_modules/karma/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" } }, - "node_modules/jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "node_modules/karma/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, - "engines": [ - "node >= 0.2.0" - ], - "license": "MIT" + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } }, - "node_modules/karma": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", - "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", + "node_modules/karma/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { - "@colors/colors": "1.5.0", - "body-parser": "^1.19.0", - "braces": "^3.0.2", - "chokidar": "^3.5.1", - "connect": "^3.7.0", - "di": "^0.0.1", - "dom-serialize": "^2.2.1", - "glob": "^7.1.7", - "graceful-fs": "^4.2.6", - "http-proxy": "^1.18.1", - "isbinaryfile": "^4.0.8", - "lodash": "^4.17.21", - "log4js": "^6.4.1", - "mime": "^2.5.2", - "minimatch": "^3.0.4", - "mkdirp": "^0.5.5", - "qjobs": "^1.2.0", - "range-parser": "^1.2.1", - "rimraf": "^3.0.2", - "socket.io": "^4.7.2", - "source-map": "^0.6.1", - "tmp": "^0.2.1", - "ua-parser-js": "^0.7.30", - "yargs": "^16.1.1" - }, - "bin": { - "karma": "bin/karma" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">= 10" + "node": ">=8" } }, - "node_modules/karma-chrome-launcher": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz", - "integrity": "sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==", + "node_modules/karma/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { - "which": "^1.2.1" + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" } }, - "node_modules/karma-chrome-launcher/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "node_modules/karma/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "isexe": "^2.0.0" + "media-typer": "0.3.0", + "mime-types": "~2.1.24" }, - "bin": { - "which": "bin/which" + "engines": { + "node": ">= 0.6" } }, - "node_modules/karma-coverage": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz", - "integrity": "sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==", + "node_modules/karma/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { - "istanbul-lib-coverage": "^3.2.0", - "istanbul-lib-instrument": "^5.1.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.1", - "istanbul-reports": "^3.0.5", - "minimatch": "^3.0.4" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=10.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/karma-coverage/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "node_modules/karma/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" }, "engines": { - "node": ">=8" + "node": ">=10" } }, - "node_modules/karma-coverage/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/karma/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "engines": { + "node": ">=10" } }, - "node_modules/karma-jasmine": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz", - "integrity": "sha512-i/zQLFrfEpRyQoJF9fsCdTMOF5c2dK7C7OmsuKg2D0YSsuZSfQDiLuaiktbuio6F2wiCsZSnSnieIQ0ant/uzQ==", + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "dev": true, "license": "MIT", - "dependencies": { - "jasmine-core": "^4.1.0" - }, "engines": { - "node": ">=12" - }, - "peerDependencies": { - "karma": "^6.0.0" + "node": ">=0.10.0" } }, - "node_modules/karma-jasmine-html-reporter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-2.1.0.tgz", - "integrity": "sha512-sPQE1+nlsn6Hwb5t+HHwyy0A1FNCVKuL1192b+XNauMYWThz2kweiBVW1DqloRpVvZIJkIoHVB7XRpK78n1xbQ==", + "node_modules/launch-editor": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz", + "integrity": "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==", "dev": true, "license": "MIT", - "peerDependencies": { - "jasmine-core": "^4.0.0 || ^5.0.0", - "karma": "^6.0.0", - "karma-jasmine": "^5.0.0" + "dependencies": { + "picocolors": "^1.1.1", + "shell-quote": "^1.8.3" } }, - "node_modules/karma-jasmine/node_modules/jasmine-core": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.6.1.tgz", - "integrity": "sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/karma-source-map-support": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", - "integrity": "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A==", + "node_modules/less": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/less/-/less-4.4.0.tgz", + "integrity": "sha512-kdTwsyRuncDfjEs0DlRILWNvxhDG/Zij4YLO4TMJgDLW+8OzpfkdPnRgrsRuY1o+oaxJGWsps5f/RVBgGmmN0w==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "source-map-support": "^0.5.5" + "copy-anything": "^2.0.1", + "parse-node-version": "^1.0.1", + "tslib": "^2.3.0" + }, + "bin": { + "lessc": "bin/lessc" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "errno": "^0.1.1", + "graceful-fs": "^4.1.2", + "image-size": "~0.5.0", + "make-dir": "^2.1.0", + "mime": "^1.4.1", + "needle": "^3.1.0", + "source-map": "~0.6.0" } }, - "node_modules/karma/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/less-loader": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-12.3.0.tgz", + "integrity": "sha512-0M6+uYulvYIWs52y0LqN4+QM9TqWAohYSNTo4htE8Z7Cn3G/qQMEmktfHmyJT23k+20kU9zHH2wrfFXkxNLtVw==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "less": "^3.5.0 || ^4.0.0", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } } }, - "node_modules/karma/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/less/node_modules/make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "color-convert": "^2.0.1" + "pify": "^4.0.1", + "semver": "^5.6.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=6" } }, - "node_modules/karma/node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "node_modules/less/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "dev": true, "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" + "optional": true, + "bin": { + "mime": "cli.js" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=4" } }, - "node_modules/karma/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "node_modules/less/node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/less/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/less/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, + "license": "BSD-3-Clause", + "optional": true, "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "node": ">=0.10.0" } }, - "node_modules/karma/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "node_modules/license-webpack-plugin": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", + "integrity": "sha512-771TFWFD70G1wLTC4oU2Cw4qvtmNrIw+wRvBtn+okgHl7slJVi7zfNcdmqDL72BojM30VNJ2UHylr1o77U37Jw==", "dev": true, "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" + "webpack-sources": "^3.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-sources": { + "optional": true + } } }, - "node_modules/karma/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, "license": "MIT", - "dependencies": { - "ms": "2.0.0" + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" } }, - "node_modules/karma/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true, "license": "MIT" }, - "node_modules/karma/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/listr2": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz", + "integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.1" + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">= 6" + "node": ">=20.0.0" } }, - "node_modules/karma/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "node_modules/listr2/node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/karma/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/lmdb": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.4.2.tgz", + "integrity": "sha512-nwVGUfTBUwJKXd6lRV8pFNfnrCC1+l49ESJRM19t/tFb/97QfJEixe5DYRvug5JO7DSFKoKaVy7oGMt5rVqZvg==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "engines": { - "node": ">=8" + "optional": true, + "dependencies": { + "msgpackr": "^1.11.2", + "node-addon-api": "^6.1.0", + "node-gyp-build-optional-packages": "5.2.2", + "ordered-binary": "^1.5.3", + "weak-lru-cache": "^1.2.2" + }, + "bin": { + "download-lmdb-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@lmdb/lmdb-darwin-arm64": "3.4.2", + "@lmdb/lmdb-darwin-x64": "3.4.2", + "@lmdb/lmdb-linux-arm": "3.4.2", + "@lmdb/lmdb-linux-arm64": "3.4.2", + "@lmdb/lmdb-linux-x64": "3.4.2", + "@lmdb/lmdb-win32-arm64": "3.4.2", + "@lmdb/lmdb-win32-x64": "3.4.2" } }, - "node_modules/karma/node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/karma/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/loader-utils": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", + "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 12.13.0" } }, - "node_modules/karma/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "p-locate": "^5.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/karma/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, - "node_modules/karma/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } + "license": "MIT" }, - "node_modules/karma/node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", "dev": true, "license": "MIT", "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" }, "engines": { - "node": ">= 0.8" - } - }, - "node_modules/karma/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" + "node": ">=18" }, - "engines": { - "node": ">=8.10.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/karma/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/karma/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/karma/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "get-east-asian-width": "^1.3.1" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/karma/node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "dev": true, "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/karma/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/karma/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "node_modules/log4js": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", + "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" + "date-format": "^4.0.14", + "debug": "^4.3.4", + "flatted": "^3.2.7", + "rfdc": "^1.3.0", + "streamroller": "^3.1.5" }, "engines": { - "node": ">=10" + "node": ">=8.0" } }, - "node_modules/karma/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", - "engines": { - "node": ">=10" + "dependencies": { + "yallist": "^3.0.2" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=0.10.0" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" } }, - "node_modules/launch-editor": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz", - "integrity": "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==", + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "license": "MIT", "dependencies": { - "picocolors": "^1.1.1", - "shell-quote": "^1.8.3" + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/less": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/less/-/less-4.4.0.tgz", - "integrity": "sha512-kdTwsyRuncDfjEs0DlRILWNvxhDG/Zij4YLO4TMJgDLW+8OzpfkdPnRgrsRuY1o+oaxJGWsps5f/RVBgGmmN0w==", + "node_modules/make-fetch-happen": { + "version": "15.0.5", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.5.tgz", + "integrity": "sha512-uCbIa8jWWmQZt4dSnEStkVC6gdakiinAm4PiGsywIkguF0eWMdcjDz0ECYhUolFU3pFLOev9VNPCEygydXnddg==", "dev": true, - "license": "Apache-2.0", + "license": "ISC", "dependencies": { - "copy-anything": "^2.0.1", - "parse-node-version": "^1.0.1", - "tslib": "^2.3.0" - }, - "bin": { - "lessc": "bin/lessc" + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" }, "engines": { - "node": ">=14" - }, - "optionalDependencies": { - "errno": "^0.1.1", - "graceful-fs": "^4.1.2", - "image-size": "~0.5.0", - "make-dir": "^2.1.0", - "mime": "^1.4.1", - "needle": "^3.1.0", - "source-map": "~0.6.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/less-loader": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-12.3.0.tgz", - "integrity": "sha512-0M6+uYulvYIWs52y0LqN4+QM9TqWAohYSNTo4htE8Z7Cn3G/qQMEmktfHmyJT23k+20kU9zHH2wrfFXkxNLtVw==", + "node_modules/make-fetch-happen/node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "dev": true, - "license": "MIT", + "license": "ISC", "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "less": "^3.5.0 || ^4.0.0", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/less/node_modules/make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "pify": "^4.0.1", - "semver": "^5.6.0" - }, "engines": { - "node": ">=6" + "node": ">= 0.4" } }, - "node_modules/less/node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "dev": true, "license": "MIT", - "optional": true, - "bin": { - "mime": "cli.js" - }, "engines": { - "node": ">=4" + "node": ">= 0.8" } }, - "node_modules/less/node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "node_modules/memfs": { + "version": "4.57.2", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.2.tgz", + "integrity": "sha512-2nWzSsJzrukurSDna4Z0WywuScK4Id3tSKejgu74u8KCdW4uNrseKRSIDg75C6Yw5ZRqBe0F0EtMNlTbUq8bAQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.2", + "@jsonjoy.com/fs-fsa": "4.57.2", + "@jsonjoy.com/fs-node": "4.57.2", + "@jsonjoy.com/fs-node-builtins": "4.57.2", + "@jsonjoy.com/fs-node-to-fsa": "4.57.2", + "@jsonjoy.com/fs-node-utils": "4.57.2", + "@jsonjoy.com/fs-print": "4.57.2", + "@jsonjoy.com/fs-snapshot": "4.57.2", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", + "tslib": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "dev": true, "license": "MIT", - "optional": true, "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/less/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true, - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver" + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" } }, - "node_modules/less/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "dev": true, - "license": "BSD-3-Clause", - "optional": true, + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 0.6" } }, - "node_modules/license-webpack-plugin": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", - "integrity": "sha512-771TFWFD70G1wLTC4oU2Cw4qvtmNrIw+wRvBtn+okgHl7slJVi7zfNcdmqDL72BojM30VNJ2UHylr1o77U37Jw==", + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "webpack-sources": "^3.0.0" + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - }, - "webpack-sources": { - "optional": true - } + "engines": { + "node": ">=8.6" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { - "node": ">=14" + "node": ">=8.6" }, "funding": { - "url": "https://github.com/sponsors/antonk52" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/listr2": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz", - "integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==", + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", "dev": true, "license": "MIT", - "dependencies": { - "cli-truncate": "^4.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" + "bin": { + "mime": "cli.js" }, "engines": { - "node": ">=20.0.0" + "node": ">=4.0.0" } }, - "node_modules/listr2/node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">= 0.6" + } }, - "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "mime-db": "^1.54.0" }, "engines": { "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/lmdb": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.4.2.tgz", - "integrity": "sha512-nwVGUfTBUwJKXd6lRV8pFNfnrCC1+l49ESJRM19t/tFb/97QfJEixe5DYRvug5JO7DSFKoKaVy7oGMt5rVqZvg==", + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "dependencies": { - "msgpackr": "^1.11.2", - "node-addon-api": "^6.1.0", - "node-gyp-build-optional-packages": "5.2.2", - "ordered-binary": "^1.5.3", - "weak-lru-cache": "^1.2.2" - }, - "bin": { - "download-lmdb-prebuilds": "bin/download-prebuilds.js" + "engines": { + "node": ">=18" }, - "optionalDependencies": { - "@lmdb/lmdb-darwin-arm64": "3.4.2", - "@lmdb/lmdb-darwin-x64": "3.4.2", - "@lmdb/lmdb-linux-arm": "3.4.2", - "@lmdb/lmdb-linux-arm64": "3.4.2", - "@lmdb/lmdb-linux-x64": "3.4.2", - "@lmdb/lmdb-win32-arm64": "3.4.2", - "@lmdb/lmdb-win32-x64": "3.4.2" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/loader-runner": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", - "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "node_modules/mini-css-extract-plugin": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.4.tgz", + "integrity": "sha512-ZWYT7ln73Hptxqxk2DxPU9MmapXRhxkJD6tkSR04dnQxm8BGu2hzgKLugK5yySD97u/8yy7Ma7E76k9ZdvtjkQ==", "dev": true, "license": "MIT", + "dependencies": { + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" + }, "engines": { - "node": ">=6.11.5" + "node": ">= 12.13.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" } }, - "node_modules/loader-utils": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", - "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - } + "license": "ISC" }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "p-locate": "^5.0.0" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=10" - }, + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "MIT" + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", "dev": true, - "license": "MIT" + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } }, - "node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "node_modules/minipass-fetch": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" }, "engines": { - "node": ">=18" + "node": "^20.17.0 || >=22.9.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "optionalDependencies": { + "iconv-lite": "^0.7.2" } }, - "node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 8" } }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" + "yallist": "^4.0.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, - "license": "MIT", + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", "dependencies": { - "get-east-asian-width": "^1.3.1" + "minipass": "^3.0.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" + "yallist": "^4.0.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "node": ">=8" } }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, - "license": "MIT", + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", + "dev": true, + "license": "ISC", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "minipass": "^7.1.2" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=8" } }, - "node_modules/log4js": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", - "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "flatted": "^3.2.7", - "rfdc": "^1.3.0", - "streamroller": "^3.1.5" + "minipass": "^7.1.2" }, "engines": { - "node": ">=8.0" + "node": ">= 18" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "yallist": "^3.0.2" + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" } }, - "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "engines": { + "node": ">=10" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.12.1.tgz", + "integrity": "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==", + "dev": true, + "license": "MIT", + "optional": true, + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", "dev": true, + "hasInstallScript": true, "license": "MIT", + "optional": true, "dependencies": { - "semver": "^7.5.3" + "node-gyp-build-optional-packages": "5.2.2" }, - "engines": { - "node": ">=10" + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" } }, - "node_modules/make-fetch-happen": { - "version": "15.0.5", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.5.tgz", - "integrity": "sha512-uCbIa8jWWmQZt4dSnEStkVC6gdakiinAm4PiGsywIkguF0eWMdcjDz0ECYhUolFU3pFLOev9VNPCEygydXnddg==", + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "@gar/promise-retry": "^1.0.0", - "@npmcli/agent": "^4.0.0", - "@npmcli/redact": "^4.0.0", - "cacache": "^20.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^5.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^6.0.0", - "ssri": "^13.0.0" + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" }, - "engines": { - "node": "^20.17.0 || >=22.9.0" + "bin": { + "multicast-dns": "cli.js" } }, - "node_modules/make-fetch-happen/node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", "dev": true, "license": "ISC", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, "engines": { - "node": ">= 0.4" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "node_modules/needle": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz", + "integrity": "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==", "dev": true, "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.3", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, "engines": { - "node": ">= 0.8" + "node": ">= 4.4.x" } }, - "node_modules/memfs": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.2.tgz", - "integrity": "sha512-2nWzSsJzrukurSDna4Z0WywuScK4Id3tSKejgu74u8KCdW4uNrseKRSIDg75C6Yw5ZRqBe0F0EtMNlTbUq8bAQ==", + "node_modules/needle/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, "dependencies": { - "@jsonjoy.com/fs-core": "4.57.2", - "@jsonjoy.com/fs-fsa": "4.57.2", - "@jsonjoy.com/fs-node": "4.57.2", - "@jsonjoy.com/fs-node-builtins": "4.57.2", - "@jsonjoy.com/fs-node-to-fsa": "4.57.2", - "@jsonjoy.com/fs-node-utils": "4.57.2", - "@jsonjoy.com/fs-print": "4.57.2", - "@jsonjoy.com/fs-snapshot": "4.57.2", - "@jsonjoy.com/json-pack": "^1.11.0", - "@jsonjoy.com/util": "^1.9.0", - "glob-to-regex.js": "^1.0.1", - "thingies": "^2.5.0", - "tree-dump": "^1.0.3", - "tslib": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, - "peerDependencies": { - "tslib": "2" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.6" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true, "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 8" - } + "optional": true }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", "dev": true, - "license": "MIT", + "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { - "node": ">= 0.6" + "node": ">= 6.13.0" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/node-gyp": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", + "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==", "dev": true, "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": ">=8.6" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8.6" + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" } }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=4.0.0" + "node": ">=20" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "node_modules/node-gyp/node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "dev": true, - "license": "MIT", + "license": "ISC", "engines": { - "node": ">= 0.6" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "mime-db": "^1.54.0" + "isexe": "^4.0.0" }, - "engines": { - "node": ">=18" + "bin": { + "node-which": "bin/which.js" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/mini-css-extract-plugin": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.4.tgz", - "integrity": "sha512-ZWYT7ln73Hptxqxk2DxPU9MmapXRhxkJD6tkSR04dnQxm8BGu2hzgKLugK5yySD97u/8yy7Ma7E76k9ZdvtjkQ==", + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", "dev": true, "license": "MIT", - "dependencies": { - "schema-utils": "^4.0.0", - "tapable": "^2.2.1" - }, "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" + "node": ">=18" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" }, "engines": { - "node": "*" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=0.10.0" } }, - "node_modules/minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "node_modules/npm-bundled": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", + "integrity": "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==", "dev": true, "license": "ISC", "dependencies": { - "minipass": "^7.0.3" + "npm-normalize-package-bin": "^5.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/minipass-fetch": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", - "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", + "node_modules/npm-install-checks": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-8.0.0.tgz", + "integrity": "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^2.0.0", - "minizlib": "^3.0.1" + "semver": "^7.1.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" - }, - "optionalDependencies": { - "iconv-lite": "^0.7.2" } }, - "node_modules/minipass-flush": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", - "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minipass": "^3.0.0" - }, + "license": "ISC", "engines": { - "node": ">= 8" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "node_modules/npm-package-arg": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.0.tgz", + "integrity": "sha512-+t2etZAGcB7TbbLHfDwooV9ppB2LhhcT6A+L9cahsf9mEUAoQ6CktLEVvEnpD0N5CkX7zJqnPGaFtoQDy9EkHQ==", "dev": true, "license": "ISC", "dependencies": { - "yallist": "^4.0.0" + "hosted-git-info": "^9.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^6.0.0" }, "engines": { - "node": ">=8" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "node_modules/npm-packlist": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", + "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", "dev": true, "license": "ISC", "dependencies": { - "minipass": "^3.0.0" + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" }, "engines": { - "node": ">=8" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "node_modules/npm-packlist/node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "dev": true, "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/minipass-pipeline/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-sized": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", - "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", + "node_modules/npm-pick-manifest": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz", + "integrity": "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==", "dev": true, "license": "ISC", "dependencies": { - "minipass": "^7.1.2" + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" }, "engines": { - "node": ">=8" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "node_modules/npm-registry-fetch": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", + "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "minipass": "^7.1.2" + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" }, "engines": { - "node": ">= 18" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "node_modules/npm-registry-fetch/node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "dev": true, - "license": "MIT", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "minimist": "^1.2.6" + "boolbase": "^1.0.0" }, - "bin": { - "mkdirp": "bin/cmd.js" + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">=0.10.0" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/msgpackr": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.12.1.tgz", - "integrity": "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==", + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", "dev": true, "license": "MIT", - "optional": true, - "optionalDependencies": { - "msgpackr-extract": "^3.0.2" + "engines": { + "node": ">= 6" } }, - "node_modules/msgpackr-extract": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", - "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + "engines": { + "node": ">= 0.4" }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/multicast-dns": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "dev": true, "license": "MIT", "dependencies": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" + "ee-first": "1.1.1" }, - "bin": { - "multicast-dns": "cli.js" + "engines": { + "node": ">= 0.8" } }, - "node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "dev": true, - "license": "ISC", + "license": "MIT", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": ">= 0.8" } }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" + "wrappy": "1" } }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "dependencies": { + "mimic-function": "^5.0.0" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/needle": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz", - "integrity": "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==", + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "iconv-lite": "^0.6.3", - "sax": "^1.2.4" - }, - "bin": { - "needle": "bin/needle" + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" }, "engines": { - "node": ">= 4.4.x" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/needle/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "node_modules/ordered-binary": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", + "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "optional": true }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", - "optional": true + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/node-forge": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", - "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", "dev": true, - "license": "(BSD-3-Clause OR GPL-2.0)", + "license": "MIT", "engines": { - "node": ">= 6.13.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/node-gyp": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", - "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==", + "node_modules/p-retry": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", "dev": true, "license": "MIT", "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "nopt": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "tar": "^7.5.4", - "tinyglobby": "^0.2.12", - "undici": "^6.25.0", - "which": "^6.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "node_modules/p-retry/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" + "engines": { + "node": ">= 4" } }, - "node_modules/node-gyp/node_modules/isexe": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", - "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "node_modules/pacote": { + "version": "21.0.4", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.0.4.tgz", + "integrity": "sha512-RplP/pDW0NNNDh3pnaoIWYPvNenS7UqMbXyvMqJczosiFWTeGGwJC2NQBLqKf4rGLFfwCOnntw1aEp9Jiqm1MA==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, "engines": { - "node": ">=20" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/node-gyp/node_modules/proc-log": { + "node_modules/pacote/node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", @@ -12101,1576 +12045,1619 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/node-gyp/node_modules/which": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", - "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "isexe": "^4.0.0" - }, - "bin": { - "node-which": "bin/which.js" + "callsites": "^3.0.0" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=6" } }, - "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/nopt": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", - "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", - "dev": true, - "license": "ISC", "dependencies": { - "abbrev": "^4.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "node_modules/parse-json/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-node-version": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", + "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 0.10" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "license": "MIT", - "engines": { - "node": ">=0.10.0" + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/npm-bundled": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", - "integrity": "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==", + "node_modules/parse5-html-rewriting-stream": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-8.0.0.tgz", + "integrity": "sha512-wzh11mj8KKkno1pZEu+l2EVeWsuKDfR5KNWZOTsslfUX8lPDZx77m9T0kIoAVkFtD1nx6YF8oh4BnPHvxMtNMw==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "npm-normalize-package-bin": "^5.0.0" + "entities": "^6.0.0", + "parse5": "^8.0.0", + "parse5-sax-parser": "^8.0.0" }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/npm-install-checks": { + "node_modules/parse5-sax-parser": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-8.0.0.tgz", - "integrity": "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==", + "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-8.0.0.tgz", + "integrity": "sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "semver": "^7.1.1" + "parse5": "^8.0.0" }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/npm-normalize-package-bin": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", - "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "dev": true, - "license": "ISC", + "license": "MIT", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">= 0.8" } }, - "node_modules/npm-package-arg": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.0.tgz", - "integrity": "sha512-+t2etZAGcB7TbbLHfDwooV9ppB2LhhcT6A+L9cahsf9mEUAoQ6CktLEVvEnpD0N5CkX7zJqnPGaFtoQDy9EkHQ==", + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^9.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^6.0.0" - }, + "license": "MIT", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=8" } }, - "node_modules/npm-packlist": { - "version": "10.0.4", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", - "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, - "license": "ISC", - "dependencies": { - "ignore-walk": "^8.0.0", - "proc-log": "^6.0.0" - }, + "license": "MIT", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=0.10.0" } }, - "node_modules/npm-packlist/node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, - "license": "ISC", + "license": "MIT", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=8" } }, - "node_modules/npm-pick-manifest": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz", - "integrity": "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==", + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true, - "license": "ISC", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "npm-install-checks": "^8.0.0", - "npm-normalize-package-bin": "^5.0.0", - "npm-package-arg": "^13.0.0", - "semver": "^7.3.5" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/npm-registry-fetch": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", - "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/redact": "^4.0.0", - "jsonparse": "^1.3.1", - "make-fetch-happen": "^15.0.0", - "minipass": "^7.0.2", - "minipass-fetch": "^5.0.0", - "minizlib": "^3.0.1", - "npm-package-arg": "^13.0.0", - "proc-log": "^6.0.0" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "20 || >=22" } }, - "node_modules/npm-registry-fetch/node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", "dev": true, "license": "MIT", "engines": { "node": ">= 6" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "node_modules/piscina": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.1.3.tgz", + "integrity": "sha512-0u3N7H4+hbr40KjuVn2uNhOcthu/9usKhnw5vT3J7ply79v3D3M8naI00el9Klcy16x557VsEkkUQaHCWFXC/g==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=20.x" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "optionalDependencies": { + "@napi-rs/nice": "^1.0.4" } }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "dev": true, - "license": "MIT" - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", "dev": true, "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, "engines": { - "node": ">= 0.8" + "node": ">=16.20.0" } }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, "engines": { - "node": ">= 0.8" + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" } }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "mimic-function": "^5.0.0" + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^10 || ^12 || >=14" } }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", "dev": true, "license": "MIT", "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" }, "engines": { - "node": ">=18" + "node": ">=14.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "postcss": "^8.0.0" } }, - "node_modules/ora": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", - "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "chalk": "^5.3.0", - "cli-cursor": "^5.0.0", - "cli-spinners": "^2.9.2", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.0.0", - "log-symbols": "^6.0.0", - "stdin-discarder": "^0.2.2", - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0" + "camelcase-css": "^2.0.1" }, "engines": { - "node": ">=18" + "node": "^12 || ^14 || >= 16" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "postcss": "^8.4.21" } }, - "node_modules/ordered-binary": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", - "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "yocto-queue": "^0.1.0" + "lilconfig": "^3.1.1" }, "engines": { - "node": ">=10" + "node": ">= 18" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + }, + "node_modules/postcss-loader": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.1.tgz", + "integrity": "sha512-0IeqyAsG6tYiDRCYKQJLAmgQr47DX6N7sFSWvQxt6AcupX8DIdmykuk/o/tx0Lze3ErGHJEp5OSRxrelC6+NdQ==", "dev": true, "license": "MIT", "dependencies": { - "p-limit": "^3.0.2" + "cosmiconfig": "^9.0.0", + "jiti": "^1.20.0", + "semver": "^7.5.4" }, "engines": { - "node": ">=10" + "node": ">= 18.12.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } } }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "node_modules/postcss-media-query-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", "dev": true, - "license": "MIT", + "license": "MIT" + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "dev": true, + "license": "ISC", "engines": { - "node": ">=18" + "node": "^10 || ^12 || >= 14" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "postcss": "^8.1.0" } }, - "node_modules/p-retry": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", - "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", "dev": true, "license": "MIT", "dependencies": { - "@types/retry": "0.12.2", - "is-network-error": "^1.0.0", - "retry": "^0.13.1" + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" }, "engines": { - "node": ">=16.17" + "node": "^10 || ^12 || >= 14" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "postcss": "^8.1.0" } }, - "node_modules/p-retry/node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, "engines": { - "node": ">= 4" + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" } }, - "node_modules/pacote": { - "version": "21.0.4", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.0.4.tgz", - "integrity": "sha512-RplP/pDW0NNNDh3pnaoIWYPvNenS7UqMbXyvMqJczosiFWTeGGwJC2NQBLqKf4rGLFfwCOnntw1aEp9Jiqm1MA==", + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", "dev": true, "license": "ISC", "dependencies": { - "@npmcli/git": "^7.0.0", - "@npmcli/installed-package-contents": "^4.0.0", - "@npmcli/package-json": "^7.0.0", - "@npmcli/promise-spawn": "^9.0.0", - "@npmcli/run-script": "^10.0.0", - "cacache": "^20.0.0", - "fs-minipass": "^3.0.0", - "minipass": "^7.0.2", - "npm-package-arg": "^13.0.0", - "npm-packlist": "^10.0.1", - "npm-pick-manifest": "^11.0.1", - "npm-registry-fetch": "^19.0.0", - "proc-log": "^6.0.0", - "promise-retry": "^2.0.1", - "sigstore": "^4.0.0", - "ssri": "^13.0.0", - "tar": "^7.4.3" - }, - "bin": { - "pacote": "bin/index.js" + "icss-utils": "^5.0.0" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" } }, - "node_modules/pacote/node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", "dev": true, - "license": "ISC", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "node_modules/postcss-nested/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, "license": "MIT", "dependencies": { - "callsites": "^3.0.0" + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" }, "engines": { - "node": ">=6" + "node": ">=4" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=4" } }, - "node_modules/parse-json/node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true, "license": "MIT" }, - "node_modules/parse-node-version": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", - "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", + "node_modules/proc-log": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", "dev": true, - "license": "MIT", + "license": "ISC", "engines": { - "node": ">= 0.10" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, "license": "MIT", "dependencies": { - "entities": "^8.0.0" + "err-code": "^2.0.2", + "retry": "^0.12.0" }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" + "engines": { + "node": ">=10" } }, - "node_modules/parse5-html-rewriting-stream": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-8.0.0.tgz", - "integrity": "sha512-wzh11mj8KKkno1pZEu+l2EVeWsuKDfR5KNWZOTsslfUX8lPDZx77m9T0kIoAVkFtD1nx6YF8oh4BnPHvxMtNMw==", + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^6.0.0", - "parse5": "^8.0.0", - "parse5-sax-parser": "^8.0.0" + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" + "engines": { + "node": ">= 0.10" } }, - "node_modules/parse5-html-rewriting-stream/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "node_modules/prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/qjobs": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", + "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "node": ">=0.9" } }, - "node_modules/parse5-sax-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-8.0.0.tgz", - "integrity": "sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw==", + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "parse5": "^8.0.0" + "side-channel": "^1.1.0" }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "license": "BSD-2-Clause", "engines": { - "node": ">=20.19.0" + "node": ">=0.6" }, "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.6" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "dev": true, "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.10" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "pify": "^2.3.0" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 6" } }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "engines": { - "node": "20 || >=22" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } + "license": "Apache-2.0" }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", "dev": true, - "license": "ISC" + "license": "MIT" }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "regenerate": "^1.4.2" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": ">=4" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "node_modules/regex-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz", + "integrity": "sha512-yXLRqatcCuKtVHsWrNg0JL3l1zGfdXeEvDa0bdu4tCDQw0RpMDZsqbkyRTUnKMR0tXF627V2oEWjBEaEdqTwtQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "license": "MIT" }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", "dev": true, "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, "engines": { - "node": ">= 6" + "node": ">=4" } }, - "node_modules/piscina": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.1.3.tgz", - "integrity": "sha512-0u3N7H4+hbr40KjuVn2uNhOcthu/9usKhnw5vT3J7ply79v3D3M8naI00el9Klcy16x557VsEkkUQaHCWFXC/g==", + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.x" + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", + "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" }, - "optionalDependencies": { - "@napi-rs/nice": "^1.0.4" + "bin": { + "regjsparser": "bin/parser" } }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", "engines": { - "node": ">=16.20.0" + "node": ">=0.10.0" } }, - "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", "dependencies": { - "playwright-core": "1.60.0" + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { - "playwright": "cli.js" + "resolve": "bin/resolve" }, "engines": { - "node": ">=18" + "node": ">= 0.4" }, - "optionalDependencies": { - "fsevents": "2.3.2" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=4" } }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "node_modules/resolve-url-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz", + "integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "adjust-sourcemap-loader": "^4.0.0", + "convert-source-map": "^1.7.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.14", + "source-map": "0.6.1" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">=12" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "node_modules/resolve-url-loader/node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", "dev": true, "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" }, "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" + "node": ">=8.9.0" } }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "node_modules/resolve-url-loader/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "camelcase-css": "^2.0.1" + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": "^12 || ^14 || >= 16" + "node": ">=18" }, - "peerDependencies": { - "postcss": "^8.4.21" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } + "node": ">= 4" } }, - "node_modules/postcss-loader": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.1.tgz", - "integrity": "sha512-0IeqyAsG6tYiDRCYKQJLAmgQr47DX6N7sFSWvQxt6AcupX8DIdmykuk/o/tx0Lze3ErGHJEp5OSRxrelC6+NdQ==", + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", - "dependencies": { - "cosmiconfig": "^9.0.0", - "jiti": "^1.20.0", - "semver": "^7.5.4" - }, "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "postcss": "^7.0.0 || ^8.0.1", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } + "iojs": ">=1.0.0", + "node": ">=0.10.0" } }, - "node_modules/postcss-media-query-parser": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", - "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "dev": true, "license": "MIT" }, - "node_modules/postcss-modules-extract-imports": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", - "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "engines": { - "node": "^10 || ^12 || >= 14" + "dependencies": { + "glob": "^7.1.3" }, - "peerDependencies": { - "postcss": "^8.1.0" + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/postcss-modules-local-by-default": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", - "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { - "icss-utils": "^5.0.0", - "postcss-selector-parser": "^7.0.0", - "postcss-value-parser": "^4.1.0" + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" }, "engines": { - "node": "^10 || ^12 || >= 14" + "node": ">=18.0.0", + "npm": ">=8.0.0" }, - "peerDependencies": { - "postcss": "^8.1.0" + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" } }, - "node_modules/postcss-modules-scope": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", - "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^7.0.0" + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" }, "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" + "node": ">= 18" } }, - "node_modules/postcss-modules-values": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", - "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", "dev": true, - "license": "ISC", - "dependencies": { - "icss-utils": "^5.0.0" - }, + "license": "MIT", "engines": { - "node": "^10 || ^12 || >= 14" + "node": ">=18" }, - "peerDependencies": { - "postcss": "^8.1.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, "funding": [ { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" + "type": "github", + "url": "https://github.com/sponsors/feross" }, { - "type": "github", - "url": "https://github.com/sponsors/ai" + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" } ], "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-nested/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" + "queue-microtask": "^1.2.2" } }, - "node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "dev": true, - "license": "MIT", + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" + "tslib": "^2.1.0" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT" }, - "node_modules/proc-log": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, - "license": "ISC", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, "license": "MIT" }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "node_modules/sass": { + "version": "1.100.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz", + "integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==", "dev": true, "license": "MIT", "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" }, "engines": { - "node": ">=10" + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "node_modules/sass-loader": { + "version": "16.0.8", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.8.tgz", + "integrity": "sha512-hcov4ZwZJIGbEuyNr9EmiTmZueyrxSToE6GOzoZnq5JM7ecRO7ttyvilPn+VmRsqiP16+VYZzVnGZj/hzZgKBA==", "dev": true, "license": "MIT", "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" + "neo-async": "^2.6.2" }, "engines": { - "node": ">= 0.10" - } - }, - "node_modules/prr": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", - "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/qjobs": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", - "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.9" + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "webpack": { + "optional": true + } } }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "node_modules/sass/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "side-channel": "^1.1.0" + "readdirp": "^5.0.0" }, "engines": { - "node": ">=0.6" + "node": ">= 20.19.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://paulmillr.com/funding/" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "node_modules/sass/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", + "optional": true, "engines": { - "node": ">= 0.6" + "node": ">=11.0.0" } }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" }, "engines": { - "node": ">= 0.10" + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "node_modules/schema-utils/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", "dependencies": { - "pify": "^2.3.0" + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true, + "license": "MIT" + }, + "node_modules/selfsigned": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", + "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", "dev": true, "license": "MIT", "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "@types/node-forge": "^1.3.0", + "node-forge": "^1" }, "engines": { - "node": ">= 6" + "node": ">=10" } }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "engines": { + "node": ">=10" } }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "dev": true, - "license": "MIT" - }, - "node_modules/regenerate-unicode-properties": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", - "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "dev": true, "license": "MIT", "dependencies": { - "regenerate": "^1.4.2" + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" }, "engines": { - "node": ">=4" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/regex-parser": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz", - "integrity": "sha512-yXLRqatcCuKtVHsWrNg0JL3l1zGfdXeEvDa0bdu4tCDQw0RpMDZsqbkyRTUnKMR0tXF627V2oEWjBEaEdqTwtQ==", + "node_modules/serialize-javascript": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "dev": true, - "license": "MIT" + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } }, - "node_modules/regexpu-core": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", - "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "node_modules/serve-index": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", + "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", "dev": true, "license": "MIT", "dependencies": { - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.2", - "regjsgen": "^0.8.0", - "regjsparser": "^0.13.0", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.2.1" + "accepts": "~1.3.8", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.8.0", + "mime-types": "~2.1.35", + "parseurl": "~1.3.3" }, "engines": { - "node": ">=4" + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/regjsparser": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", - "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", + "node_modules/serve-index/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "jsesc": "~3.1.0" + "mime-types": "~2.1.34", + "negotiator": "0.6.3" }, - "bin": { - "regjsparser": "bin/parser" + "engines": { + "node": ">= 0.6" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=0.10.0" + "dependencies": { + "ms": "2.0.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 0.6" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.6" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "node_modules/serve-index/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">= 0.6" } }, - "node_modules/resolve-url-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz", - "integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==", + "node_modules/serve-index/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", "dependencies": { - "adjust-sourcemap-loader": "^4.0.0", - "convert-source-map": "^1.7.0", - "loader-utils": "^2.0.0", - "postcss": "^8.2.14", - "source-map": "0.6.1" + "mime-db": "1.52.0" }, "engines": { - "node": ">=12" + "node": ">= 0.6" } }, - "node_modules/resolve-url-loader/node_modules/loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/serve-index/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "dev": true, "license": "MIT", - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, "engines": { - "node": ">=8.9.0" + "node": ">= 0.6" } }, - "node_modules/resolve-url-loader/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 0.6" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "dev": true, "license": "MIT", "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">=18" + "node": ">= 18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } + "license": "ISC" }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", "dev": true, "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" + "shebang-regex": "^3.0.0" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": ">=8" } }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" + "node": ">=8" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "dev": true, "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, "engines": { - "node": ">= 18" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dev": true, "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, "engines": { - "node": ">=18" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], "license": "MIT", "dependencies": { - "queue-microtask": "^1.2.2" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", "dependencies": { - "tslib": "^2.1.0" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", - "is-regex": "^1.2.1" + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -13679,1621 +13666,1717 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "license": "MIT" + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, - "node_modules/sass": { - "version": "1.100.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz", - "integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==", + "node_modules/sigstore": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.1.tgz", + "integrity": "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "chokidar": "^5.0.0", - "immutable": "^5.1.5", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" }, "engines": { - "node": ">=20.19.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/sass-loader": { - "version": "16.0.8", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.8.tgz", - "integrity": "sha512-hcov4ZwZJIGbEuyNr9EmiTmZueyrxSToE6GOzoZnq5JM7ecRO7ttyvilPn+VmRsqiP16+VYZzVnGZj/hzZgKBA==", + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", "dev": true, "license": "MIT", "dependencies": { - "neo-async": "^2.6.2" + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" }, "engines": { - "node": ">= 18.12.0" + "node": ">=12" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", - "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", - "sass": "^1.3.0", - "sass-embedded": "*", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "node-sass": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "webpack": { - "optional": true - } + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/sass/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", "dev": true, "license": "MIT", "dependencies": { - "readdirp": "^5.0.0" + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" }, "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": ">=10.2.0" } }, - "node_modules/sass/node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "node_modules/socket.io-adapter": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz", + "integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.20.1" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", "dev": true, "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, "engines": { - "node": ">= 20.19.0" + "node": ">=10.0.0" + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "engines": { + "node": ">= 0.6" } }, - "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, - "license": "BlueOak-1.0.0", - "optional": true, + "license": "MIT", "engines": { - "node": ">=11.0.0" + "node": ">= 0.6" } }, - "node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" + "mime-db": "1.52.0" }, "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "node": ">= 0.6" } }, - "node_modules/schema-utils/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "dev": true, "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } + "engines": { + "node": ">= 0.6" } }, - "node_modules/select-hose": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } }, - "node_modules/selfsigned": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", - "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "dev": true, "license": "MIT", "dependencies": { - "@types/node-forge": "^1.3.0", - "node-forge": "^1" + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" }, "engines": { - "node": ">=10" + "node": ">= 10.0.0", + "npm": ">= 3.0.0" } }, - "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" }, "engines": { - "node": ">=10" + "node": ">= 14" } }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, + "license": "BSD-3-Clause", "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 12" } }, - "node_modules/serialize-javascript": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", - "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { - "node": ">=20.0.0" + "node": ">=0.10.0" } }, - "node_modules/serve-index": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", - "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", + "node_modules/source-map-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", + "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", "dev": true, "license": "MIT", "dependencies": { - "accepts": "~1.3.8", - "batch": "0.6.1", - "debug": "2.6.9", - "escape-html": "~1.0.3", - "http-errors": "~1.8.0", - "mime-types": "~2.1.35", - "parseurl": "~1.3.3" + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.2" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18.12.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.72.1" } }, - "node_modules/serve-index/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "node_modules/source-map-loader/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">=0.10.0" } }, - "node_modules/serve-index/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/serve-index/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" } }, - "node_modules/serve-index/node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" - }, + "license": "BSD-3-Clause", "engines": { - "node": ">= 0.6" + "node": ">=0.10.0" } }, - "node_modules/serve-index/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "license": "CC-BY-3.0" }, - "node_modules/serve-index/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", "dev": true, "license": "MIT", "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" } }, - "node_modules/serve-index/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "dev": true, - "license": "MIT" + "license": "CC0-1.0" }, - "node_modules/serve-index/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", "dev": true, "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, "engines": { - "node": ">= 0.6" + "node": ">=6.0.0" } }, - "node_modules/serve-index/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" } }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "node_modules/ssri": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" + "minipass": "^7.0.3" }, "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "dev": true, - "license": "ISC" + "license": "MIT", + "engines": { + "node": ">= 0.8" + } }, - "node_modules/shallow-clone": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", - "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", "dev": true, "license": "MIT", - "dependencies": { - "kind-of": "^6.0.2" - }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/streamroller": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", + "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", "dev": true, "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" + "date-format": "^4.0.14", + "debug": "^4.3.4", + "fs-extra": "^8.1.0" }, "engines": { - "node": ">=8" + "node": ">=8.0" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "safe-buffer": "~5.2.0" } }, - "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" + "ansi-regex": "^6.2.2" }, "engines": { - "node": ">= 0.4" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "node_modules/style-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", "dev": true, "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, "engines": { - "node": ">= 0.4" + "node": ">= 18.12.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" } }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" }, - "engines": { - "node": ">= 0.4" + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=16 || 14 >=14.17" } }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" + "has-flag": "^4.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, - "license": "ISC", + "license": "MIT", "engines": { - "node": ">=14" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/sigstore": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.1.tgz", - "integrity": "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==", + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.2.1", - "@sigstore/protobuf-specs": "^0.5.0", - "@sigstore/sign": "^4.1.1", - "@sigstore/tuf": "^4.0.2", - "@sigstore/verify": "^3.1.1" + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=14.0.0" } }, - "node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "node_modules/tailwindcss/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" }, "engines": { - "node": ">=12" + "node": ">= 8.10.0" }, "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" } }, - "node_modules/socket.io": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", - "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.4.1", - "engine.io": "~6.6.0", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" + "is-glob": "^4.0.1" }, "engines": { - "node": ">=10.2.0" + "node": ">= 6" } }, - "node_modules/socket.io-adapter": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz", - "integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==", + "node_modules/tailwindcss/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", - "dependencies": { - "debug": "~4.4.1", - "ws": "~8.20.1" + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/socket.io-parser": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", - "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "node_modules/tailwindcss/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, "license": "MIT", "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1" + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" }, "engines": { - "node": ">=10.0.0" + "node": ">=4" } }, - "node_modules/socket.io/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "node_modules/tailwindcss/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "picomatch": "^2.2.1" }, "engines": { - "node": ">= 0.6" + "node": ">=8.10.0" } }, - "node_modules/socket.io/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/socket.io/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/tar": { + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "mime-db": "1.52.0" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" } }, - "node_modules/socket.io/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "engines": { - "node": ">= 0.6" - } - }, - "node_modules/sockjs": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", - "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "faye-websocket": "^0.11.3", - "uuid": "^8.3.2", - "websocket-driver": "^0.7.4" + "node": ">=18" } }, - "node_modules/socks": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", - "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "node_modules/terser": { + "version": "5.43.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", + "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.14.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" }, "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" + "node": ">=10" } }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "node_modules/terser-webpack-plugin": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz", + "integrity": "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" }, "engines": { - "node": ">= 14" - } - }, - "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } + "license": "MIT" }, - "node_modules/source-map-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", - "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", "dev": true, "license": "MIT", "dependencies": { - "iconv-lite": "^0.6.3", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.72.1" + "any-promise": "^1.0.0" } }, - "node_modules/source-map-loader/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", "dev": true, "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "thenify": ">= 3.1.0 < 4" }, "engines": { - "node": ">=0.10.0" + "node": ">=0.8" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "node_modules/thingies": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", + "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", "dev": true, "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", - "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" + "node": ">=10.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "^2" } }, - "node_modules/spdx-license-ids": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", - "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "dev": true, - "license": "CC0-1.0" + "license": "MIT" }, - "node_modules/spdy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", - "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.1.0", - "handle-thing": "^2.0.0", - "http-deceiver": "^1.2.7", - "select-hose": "^2.0.0", - "spdy-transport": "^3.0.0" + "fdir": "^6.4.4", + "picomatch": "^4.0.2" }, "engines": { - "node": ">=6.0.0" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/spdy-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", - "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "dev": true, "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "detect-node": "^2.0.4", - "hpack.js": "^2.1.6", - "obuf": "^1.1.2", - "readable-stream": "^3.0.6", - "wbuf": "^1.7.3" + "engines": { + "node": ">=14.14" } }, - "node_modules/ssri": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", - "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "minipass": "^7.0.3" + "is-number": "^7.0.0" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=8.0" } }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=0.6" } }, - "node_modules/stdin-discarder": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "node_modules/tree-dump": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=10.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/streamroller": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", - "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", "dev": true, "license": "MIT", - "dependencies": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "fs-extra": "^8.1.0" - }, - "engines": { - "node": ">=8.0" + "bin": { + "tree-kill": "cli.js" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } + "license": "Apache-2.0" }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tuf-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", + "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.2.2" + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { - "node": ">=12" + "node": ">= 18" }, "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/style-loader": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", - "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 18.12.0" + "node": ">=18" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://opencollective.com/express" + } + }, + "node_modules/typed-assert": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/typed-assert/-/typed-assert-1.0.9.tgz", + "integrity": "sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, - "peerDependencies": { - "webpack": "^5.27.0" + "engines": { + "node": ">=14.17" } }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "node_modules/ua-parser-js": { + "version": "0.7.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", + "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" + "ua-parser-js": "script/cli.js" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "*" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/undici": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": ">=18.17" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=4" } }, - "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", "dev": true, "license": "MIT", "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=4" } }, - "node_modules/tailwindcss/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", "dev": true, "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "node": ">=4" } }, - "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, + "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=4" } }, - "node_modules/tailwindcss/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">= 4.0.0" } }, - "node_modules/tailwindcss/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "dev": true, "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, "engines": { - "node": ">=4" + "node": ">= 0.8" } }, - "node_modules/tailwindcss/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "picomatch": "^2.2.1" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" }, - "engines": { - "node": ">=8.10.0" + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" } }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "node": ">= 0.4.0" } }, - "node_modules/tar": { - "version": "7.5.15", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", - "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" } }, - "node_modules/tar/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "node_modules/validate-npm-package-name": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.2.tgz", + "integrity": "sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "ISC", "engines": { - "node": ">=18" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/terser": { - "version": "5.43.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", - "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.14.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">= 0.8" } }, - "node_modules/terser-webpack-plugin": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz", - "integrity": "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==", + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "terser": "^5.31.1" + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" }, "engines": { - "node": ">= 10.13.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" }, "peerDependencies": { - "webpack": "^5.1.0" + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { - "@minify-html/node": { - "optional": true - }, - "@swc/core": { + "@types/node": { "optional": true }, - "@swc/css": { + "jiti": { "optional": true }, - "@swc/html": { + "less": { "optional": true }, - "clean-css": { + "lightningcss": { "optional": true }, - "cssnano": { + "sass": { "optional": true }, - "csso": { + "sass-embedded": { "optional": true }, - "esbuild": { + "stylus": { "optional": true }, - "html-minifier-terser": { + "sugarss": { "optional": true }, - "lightningcss": { + "terser": { "optional": true }, - "postcss": { + "tsx": { "optional": true }, - "uglify-js": { + "yaml": { "optional": true } } }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=0.8" + "node": ">=18" } }, - "node_modules/thingies": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", - "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=10.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "^2" + "node": ">=18" } }, - "node_modules/thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "node": ">=18" } }, - "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=14.14" + "node": ">=18" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=8.0" + "node": ">=18" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.6" + "node": ">=18" } }, - "node_modules/tree-dump": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", - "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" + "node": ">=18" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "bin": { - "tree-kill": "cli.js" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tuf-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", - "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@tufjs/models": "4.1.0", - "debug": "^4.4.3", - "make-fetch-happen": "^15.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=18" } }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], "dev": true, "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=18" } }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, - "node_modules/typed-assert": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/typed-assert/-/typed-assert-1.0.9.tgz", - "integrity": "sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==", - "dev": true, - "license": "MIT" - }, - "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14.17" + "node": ">=18" } }, - "node_modules/ua-parser-js": { - "version": "0.7.41", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", - "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" ], + "dev": true, "license": "MIT", - "bin": { - "ua-parser-js": "script/cli.js" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "*" + "node": ">=18" } }, - "node_modules/undici": { - "version": "6.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", - "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.17" + "node": ">=18" } }, - "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "dev": true, - "license": "MIT" - }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=4" + "node": ">=18" } }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=4" + "node": ">=18" } }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", - "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=4" + "node": ">=18" } }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", - "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=4" + "node": ">=18" } }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">= 4.0.0" + "node": ">=18" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">= 0.8" + "node": ">=18" } }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" ], + "dev": true, "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.4.0" + "node": ">=18" } }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, + "hasInstallScript": true, "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, - "node_modules/validate-npm-package-name": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.2.tgz", - "integrity": "sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ==", + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "license": "ISC", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "node_modules/vite/node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, "engines": { - "node": ">= 0.8" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, "node_modules/void-elements": { @@ -15306,6 +15389,20 @@ "node": ">=0.10.0" } }, + "node_modules/watchpack": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", + "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/wbuf": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 66648fc..ab220ef 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,21 +14,21 @@ }, "private": true, "dependencies": { - "@angular/animations": "20.3.25", + "@angular/animations": "20.3.27", "@angular/cdk": "^20.2.2", - "@angular/common": "20.3.25", - "@angular/compiler": "20.3.25", - "@angular/core": "20.3.25", - "@angular/forms": "20.3.25", - "@angular/platform-browser": "20.3.25", - "@angular/platform-browser-dynamic": "20.3.25", - "@angular/router": "20.3.25", + "@angular/common": "20.3.27", + "@angular/compiler": "20.3.27", + "@angular/core": "20.3.27", + "@angular/forms": "20.3.27", + "@angular/platform-browser": "20.3.27", + "@angular/platform-browser-dynamic": "20.3.27", + "@angular/router": "20.3.27", "zone.js": "~0.15.1" }, "devDependencies": { "@angular-devkit/build-angular": "^20.2.2", "@angular/cli": "^20.2.2", - "@angular/compiler-cli": "20.3.25", + "@angular/compiler-cli": "20.3.27", "@playwright/test": "^1.54.2", "@types/jasmine": "~5.1.0", "autoprefixer": "^10.4.21", diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index 5b79d40..934fa80 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,4 +1,4 @@ -import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +import { ApplicationConfig, ErrorHandler, provideZoneChangeDetection } from '@angular/core'; import { provideRouter, withComponentInputBinding, @@ -12,6 +12,8 @@ import { provideAnimationsAsync } from '@angular/platform-browser/animations/asy import { authInterceptor } from './services/auth.interceptor'; import { orgInterceptor } from './services/org.interceptor'; import { localeInterceptor } from './services/locale.interceptor'; +import { diagnosticInterceptor } from './services/diagnostic.interceptor'; +import { GlobalErrorHandler } from './services/global-error.handler'; export const appConfig: ApplicationConfig = { providers: [ @@ -21,7 +23,8 @@ export const appConfig: ApplicationConfig = { withComponentInputBinding(), withNavigationErrorHandler(recoverFromNavigationError), ), - provideHttpClient(withInterceptors([authInterceptor, orgInterceptor, localeInterceptor])), - provideAnimationsAsync() + provideHttpClient(withInterceptors([authInterceptor, orgInterceptor, localeInterceptor, diagnosticInterceptor])), + provideAnimationsAsync(), + { provide: ErrorHandler, useClass: GlobalErrorHandler }, ] }; diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index d704652..bb85ec5 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,6 +1,8 @@ import { Routes } from '@angular/router'; import { RoleGuard } from './guards/role.guard'; import { HomeSessionGuard } from './guards/home-session.guard'; +import { environment } from '../environments/environment'; +import { courseStudioExitGuard } from './guards/course-studio-exit.guard'; const AccessDeniedComponent = () => import('./pages/access-denied/access-denied.component').then((m) => m.AccessDeniedComponent); const AccessGrantsComponent = () => import('./pages/access-grants/access-grants.component').then((m) => m.AccessGrantsComponent); @@ -17,6 +19,8 @@ const AvailableCoursesComponent = () => import('./pages/available-courses/availa const CertificationsComponent = () => import('./pages/certifications/certifications.component').then((m) => m.CertificationsComponent); const CommercialDashboardComponent = () => import('./pages/commercial-dashboard/commercial-dashboard.component').then((m) => m.CommercialDashboardComponent); const CourseWizardComponent = () => import('./pages/admin/course-wizard/course-wizard.component').then((m) => m.CourseWizardComponent); +const CourseStudioComponent = () => import('./pages/course-studio/course-studio.component').then((m) => m.CourseStudioComponent); +const CanonicalCourseAuthorComponent = environment.courseAuthoringStudioEnabled ? CourseStudioComponent : CourseWizardComponent; const DemoReadinessComponent = () => import('./pages/demo-readiness/demo-readiness.component').then((m) => m.DemoReadinessComponent); const EchoedRoleSelectorComponent = () => import('./pages/user-dashboard/echoed-role-selector/echoed-role-selector.component').then((m) => m.EchoedRoleSelectorComponent); const GenerationRunDetailComponent = () => import('./pages/generation-run-detail/generation-run-detail.component').then((m) => m.GenerationRunDetailComponent); @@ -66,7 +70,6 @@ const WorkspaceAnalyticsComponent = () => import('./pages/workspace-analytics/wo const WorkspaceDashboardComponent = () => import('./pages/workspace-dashboard/workspace-dashboard.component').then((m) => m.WorkspaceDashboardComponent); const creatorRoles = ['admin', 'teacher', 'content_admin', 'org_admin', 'instructor']; -const studioRoles = ['content_admin', 'org_admin']; const sectionRoles = ['teacher', 'org_admin', 'instructor']; export const routes: Routes = [ @@ -78,6 +81,20 @@ export const routes: Routes = [ { path: 'onboarding/organization', loadComponent: OnboardingOrganizationComponent }, { path: 'access-denied', loadComponent: AccessDeniedComponent }, { path: 'load-error', loadComponent: RouteLoadErrorComponent }, + { + path: 'workspace/product-studio/courses/new', + loadComponent: CanonicalCourseAuthorComponent, + canActivate: [HomeSessionGuard], + canDeactivate: [courseStudioExitGuard], + pathMatch: 'full', + }, + { + path: 'workspace/product-studio/courses/:courseId', + loadComponent: CanonicalCourseAuthorComponent, + canActivate: [HomeSessionGuard], + canDeactivate: [courseStudioExitGuard], + pathMatch: 'full', + }, { path: 'admin', loadComponent: HomeComponent, @@ -193,15 +210,13 @@ export const routes: Routes = [ { path: '', loadComponent: EchoedRoleSelectorComponent }, { path: 'courses/new', - loadComponent: CourseWizardComponent, - canActivate: [RoleGuard], - data: { roles: ['admin', 'teacher'] } + redirectTo: '/workspace/product-studio/courses/new', + pathMatch: 'full' }, { path: 'courses/:courseId/edit', - loadComponent: CourseWizardComponent, - canActivate: [RoleGuard], - data: { roles: ['admin', 'teacher'] } + redirectTo: '/workspace/product-studio/courses/:courseId', + pathMatch: 'full' }, { path: 'courses', loadComponent: AvailableCoursesComponent }, { path: 'programs', loadComponent: ProgramsComponent }, @@ -264,7 +279,8 @@ export const routes: Routes = [ path: '', loadComponent: WorkspaceDashboardComponent, canActivate: [RoleGuard], - data: { roles: creatorRoles } + data: { roles: creatorRoles }, + pathMatch: 'full' }, { path: 'projects', @@ -289,13 +305,15 @@ export const routes: Routes = [ path: 'product-studio', loadComponent: ProductStudioComponent, canActivate: [RoleGuard], - data: { roles: creatorRoles } + data: { roles: creatorRoles }, + pathMatch: 'full' }, { path: 'product-studio/create', loadComponent: ProductStudioComponent, canActivate: [RoleGuard], - data: { roles: creatorRoles } + data: { roles: creatorRoles }, + pathMatch: 'full' }, { path: 'product-studio/generation-runs', @@ -319,20 +337,12 @@ export const routes: Routes = [ { path: 'product-studio/courses', loadComponent: StudioCoursesComponent, - canActivate: [RoleGuard], - data: { roles: studioRoles } - }, - { - path: 'product-studio/courses/new', - loadComponent: CourseWizardComponent, - canActivate: [RoleGuard], - data: { roles: ['admin', 'teacher'] } + pathMatch: 'full' }, { path: 'product-studio/courses/:courseId/edit', - loadComponent: CourseWizardComponent, - canActivate: [RoleGuard], - data: { roles: ['admin', 'teacher'] } + redirectTo: '/workspace/product-studio/courses/:courseId', + pathMatch: 'full' }, { path: 'products', diff --git a/frontend/src/app/guards/course-studio-exit.guard.ts b/frontend/src/app/guards/course-studio-exit.guard.ts new file mode 100644 index 0000000..f6703fd --- /dev/null +++ b/frontend/src/app/guards/course-studio-exit.guard.ts @@ -0,0 +1,5 @@ +import { CanDeactivateFn } from '@angular/router'; +import { CourseStudioComponent } from '../pages/course-studio/course-studio.component'; + +export const courseStudioExitGuard: CanDeactivateFn = component => + !component.dirty || confirm('This course still has unsaved changes. Leave without saving them?'); diff --git a/frontend/src/app/models/course-authoring.model.ts b/frontend/src/app/models/course-authoring.model.ts new file mode 100644 index 0000000..c02ad51 --- /dev/null +++ b/frontend/src/app/models/course-authoring.model.ts @@ -0,0 +1,154 @@ +export interface CourseAuthoringCapabilities { + can_create: boolean; + can_view_draft: boolean; + can_edit: boolean; + can_duplicate: boolean; + can_preview: boolean; + can_submit_review: boolean; + can_review: boolean; + can_publish: boolean; +} + +export interface CourseAuthoringCapabilityEnvelope { + organization_id?: string | null; + course_id?: string | null; + capabilities: CourseAuthoringCapabilities; +} + +export interface AuthoringStorybookPage { + id?: string; + image_url: string; + order?: number; +} + +export interface AuthoringActivity { + id?: string; + type: string; + title: string; + content: string; + order?: number; + media_id?: string | null; + pages: AuthoringStorybookPage[]; +} + +export interface AuthoringSource { + id?: string; + citation: string; + url?: string | null; +} + +export interface AuthoringLesson { + id?: string; + title: string; + objective?: string | null; + learning_objectives?: string | null; + key_concepts: string[]; + teacher_notes?: string | null; + discussion_questions: string[]; + hook?: string | null; + content?: string | null; + guided_practice?: string | null; + independent_practice?: string | null; + assessment?: string | null; + skill_tags: string[]; + standards_metadata: Record; + order?: number; + duration_minutes?: number | null; + activities: AuthoringActivity[]; + sources: AuthoringSource[]; + assessment_ids?: string[]; +} + +export interface AuthoringUnit { + id?: string; + title: string; + content?: string | null; + order?: number; + lessons: AuthoringLesson[]; + assessment_ids?: string[]; +} + +export interface CourseAuthoringDraft { + id?: string; + title: string; + description: string; + subject?: string | null; + age_band_min?: number | null; + age_band_max?: number | null; + default_locale: string; + learning_objectives?: string | null; + skill_tags: string[]; + standards_metadata: Record; + organization_id?: string | null; + created_by?: string | null; + revision_number?: number; + revision_status?: string; + revision_metadata?: Record; + updated_at?: string; + current_version_id?: string | null; + units: AuthoringUnit[]; + assessment_ids?: string[]; + capabilities?: CourseAuthoringCapabilities; + template_id?: string | null; +} + +export interface CourseAuthoringValidationIssue { + severity: 'blocking' | 'warning' | 'recommendation'; + entity_type: string; + entity_id?: string | null; + field: string; + message: string; + corrective_context: string; +} + +export interface CourseAuthoringConflict { + code: 'course_authoring_revision_conflict'; + course_id: string; + current_revision: number; + updated_at?: string | null; +} + +export interface CourseLifecycleResponse { + course_id: string; + lifecycle_state: string; + revision_number: number; + version_id?: string | null; + feedback?: string | null; + changed_at: string; +} + +export interface CourseVersionSummary { + id: string; + course_id: string; + version_number: number; + status: string; + changelog?: string | null; + created_at: string; + published_at?: string | null; + published_by?: string | null; +} + +export interface CourseTemplate { + id: string; + name: string; + description: string; + course: Partial; +} + +export type CourseStudioMode = 'setup' | 'build' | 'quality' | 'preview' | 'release'; +export type CourseSaveState = 'idle' | 'dirty' | 'saving' | 'saved' | 'failed' | 'offline' | 'conflict'; + +export function emptyCourseAuthoringDraft(): CourseAuthoringDraft { + return { + title: '', + description: '', + subject: null, + age_band_min: null, + age_band_max: null, + default_locale: 'en', + learning_objectives: null, + skill_tags: [], + standards_metadata: {}, + units: [], + }; +} diff --git a/frontend/src/app/models/organization.ts b/frontend/src/app/models/organization.ts index 5b5e1ec..3dd4ef4 100644 --- a/frontend/src/app/models/organization.ts +++ b/frontend/src/app/models/organization.ts @@ -13,7 +13,7 @@ export interface OrganizationInvite { organization_id: string; email: string; role: string; - token: string; + token?: string; expires_at: string; accepted_at?: string | null; invited_by_user_id: string; diff --git a/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.spec.ts b/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.spec.ts index 34cda3d..45abafd 100644 --- a/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.spec.ts +++ b/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.spec.ts @@ -37,4 +37,12 @@ describe('AdminUserDetailComponent', () => { fixture.componentInstance.selectedRole = 'admin'; fixture.componentInstance.requestRoleChange(); fixture.componentInstance.applyRoleChange(); expect(fixture.componentInstance.user?.role).toBe('student'); expect(fixture.componentInstance.actionError).toContain('not changed'); }); + + it('announces final-administrator conflict details returned by the hardened API', () => { + users.updateUserRole.and.returnValue(throwError(() => ({ status: 409, error: { detail: 'This action would remove the final platform super administrator.' } }))); + fixture.componentInstance.selectedRole = 'teacher'; fixture.componentInstance.requestRoleChange(); fixture.componentInstance.applyRoleChange(); + fixture.detectChanges(); + expect(fixture.componentInstance.actionError).toContain('final platform super administrator'); + expect(fixture.nativeElement.textContent).toContain('final platform super administrator'); + }); }); diff --git a/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.ts b/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.ts index 7f174a5..ecaabb1 100644 --- a/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.ts +++ b/frontend/src/app/pages/admin-user-detail/admin-user-detail.component.ts @@ -10,6 +10,7 @@ import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo- import { User } from '../../models/user'; import { PermissionsService } from '../../services/permissions.service'; import { UsersService } from '../../services/users.service'; +import { securityErrorMessage } from '../../services/security-error'; @Component({ selector: 'app-admin-user-detail', @@ -19,7 +20,7 @@ import { UsersService } from '../../services/users.service'; styleUrl: './admin-user-detail.component.scss', }) export class AdminUserDetailComponent implements OnInit, OnDestroy { - readonly assignableRoles = ['student', 'teacher', 'admin']; + assignableRoles = ['student', 'teacher']; user?: User; selectedRole = ''; currentUserId = ''; @@ -38,7 +39,12 @@ export class AdminUserDetailComponent implements OnInit, OnDestroy { readonly router: Router, private readonly users: UsersService, permissions: PermissionsService, - ) { this.subscriptions.add(permissions.user$.subscribe(user => this.currentUserId = user?.user_id ?? '')); } + ) { this.subscriptions.add(permissions.user$.subscribe(user => { + this.currentUserId = user?.user_id ?? ''; + this.assignableRoles = user?.role === 'super_admin' + ? ['student', 'teacher', 'instructor', 'parent', 'content_admin', 'org_admin', 'admin', 'super_admin'] + : ['student', 'teacher', 'instructor', 'parent', 'content_admin', 'org_admin']; + })); } ngOnInit(): void { this.load(); } ngOnDestroy(): void { this.subscriptions.unsubscribe(); } @@ -69,7 +75,7 @@ export class AdminUserDetailComponent implements OnInit, OnDestroy { this.saving = false; this.confirmRole = false; this.successMessage = `Role updated to ${this.roleLabel(this.selectedRole)}.`; }, - error: () => { this.saving = false; this.actionError = 'The role was not changed. Review the account and try again.'; }, + error: error => { this.saving = false; this.actionError = securityErrorMessage(error, 'The role was not changed. Review the account and try again.'); }, })); } @@ -78,7 +84,7 @@ export class AdminUserDetailComponent implements OnInit, OnDestroy { this.deleting = true; this.actionError = ''; this.subscriptions.add(this.users.deleteUser(this.user.id).subscribe({ next: () => this.router.navigate(['/admin/users']), - error: () => { this.deleting = false; this.actionError = 'The user was not deleted. No local changes were made.'; }, + error: error => { this.deleting = false; this.actionError = securityErrorMessage(error, 'The user was not deleted. No local changes were made.'); }, })); } diff --git a/frontend/src/app/pages/admin-users/admin-users.component.ts b/frontend/src/app/pages/admin-users/admin-users.component.ts index 0bc6d8a..e61e55f 100644 --- a/frontend/src/app/pages/admin-users/admin-users.component.ts +++ b/frontend/src/app/pages/admin-users/admin-users.component.ts @@ -10,6 +10,7 @@ import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo- import { User } from '../../models/user'; import { PermissionsService } from '../../services/permissions.service'; import { UsersService } from '../../services/users.service'; +import { securityErrorMessage } from '../../services/security-error'; @Component({ selector: 'admin-users-page', @@ -74,9 +75,9 @@ export class AdminUsersComponent implements OnInit, OnDestroy { this.deleting = false; this.pendingDelete = undefined; }, - error: () => { + error: error => { this.deleting = false; - this.deleteError = 'The user was not deleted. No local changes were made.'; + this.deleteError = securityErrorMessage(error, 'The user was not deleted. No local changes were made.'); }, })); } diff --git a/frontend/src/app/pages/course-studio/course-studio.component.html b/frontend/src/app/pages/course-studio/course-studio.component.html new file mode 100644 index 0000000..38b889c --- /dev/null +++ b/frontend/src/app/pages/course-studio/course-studio.component.html @@ -0,0 +1,106 @@ +
+
+
Courses

{{ draft.title || 'Untitled course' }}

{{ draft.revision_status || 'Draft' }} · Revision {{ draft.revision_number || 0 }}

+
{{ saveState === 'saved' && savedAt ? 'Saved ' + (savedAt | date:'shortTime') : (saveState | titlecase) }}
+
+ +
+ +
+ + + + +
+ + +
+ +

Course setup

Start with the learner promise. You can build the outline before every detail is final.

+
+
+ + + + + + + + + + + +
+
+
+ + +
+
+

Unit {{ selectedUnit + 1 }}

+ + + +
+ +
+
+

Lesson {{ selectedLesson + 1 }}

+ + + + +

Activities

+
+
Activity {{ ai + 1 }}
+
+
  1. Page {{ page.order || 'new' }}: {{ page.image_url }}
+
+
+

{{ uploadMessage }}

+

Sources

+
+
+

Build your outline

Add a unit, then organize lessons and activities within it.

+
+
+ + +

Quality check

Blocking issues must be fixed before review. Warnings are useful prompts, not hidden gates.

+
{{ localIssues.length }} authoring findings{{ governance?.publish_readiness?.blocking_issue_count || 0 }} governance blockers
+
  • {{ issue.severity | titlecase }}
    {{ issue.message }}

    {{ issue.corrective_context }}

+

Governance findings

  • Blocking
    {{ issue.message }}

    {{ issue.entity_title }}

  • Warning
    {{ issue.message }}

    {{ issue.entity_title }}

+
No authoring issues found. Human review is still required before publishing.
+
+ + +

Learner preview

This preview uses draft content for staff only and does not make the course available to learners.

+

{{ learnerPreview?.title || draft.title || 'Untitled course' }}

{{ learnerPreview?.description || draft.description }}

{{ unit.title }}

{{ lesson.title }}

{{ lesson.objective || lesson.learning_objectives }}

{{ activity.title }}

{{ activity.content }}

Save the course once to load its learner-safe server preview.

+
+ + +

Review and release

Publishing requires an independent human approval and a successful safe-publish check.

+
Status
{{ draft.revision_status || 'draft' | titlecase }}
Revision
{{ draft.revision_number || 0 }}
+ + + +

Save all changes before a lifecycle action.

+

{{ lifecycleMessage }}

+

Version history

  1. Version {{ version.version_number }} — {{ version.status | titlecase }}, published {{ version.published_at | date:'medium' }}
+

Common Cartridge and QTI import are not supported in this release; validation adapters reserve that boundary.

+
+
+
+
+ +

Opening Course Studio…

You don’t have course authoring access

Your organization role does not allow this action. Ask an organization administrator if you need access.

Back to courses

Course not found

It may have been removed or belong to another organization.

Back to courses

Course Studio could not load

Your work has not been changed.

diff --git a/frontend/src/app/pages/course-studio/course-studio.component.scss b/frontend/src/app/pages/course-studio/course-studio.component.scss new file mode 100644 index 0000000..07c6775 --- /dev/null +++ b/frontend/src/app/pages/course-studio/course-studio.component.scss @@ -0,0 +1,14 @@ +:host { display:block; color:#172033; } +.studio { max-width:1440px; margin:0 auto; padding:1.25rem; } +.studio-header,.editor-heading,.outline-heading,.save-actions { display:flex; align-items:center; justify-content:space-between; gap:.75rem; } +h1,h2,h3,h4,p { margin-top:0; } .studio-header h1 { margin:.25rem 0; } .studio-header p,.lede,small { color:#596579; } +button,a { min-height:44px; } button { border:1px solid #bac4d4; border-radius:.5rem; padding:.55rem .8rem; background:#fff; cursor:pointer; } button:hover:not(:disabled),button:focus-visible { border-color:#365bd6; } button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,a:focus-visible { outline:3px solid #93b4ff; outline-offset:2px; } +.mode-tabs { display:flex; gap:.3rem; overflow:auto; margin:1rem 0; border-bottom:1px solid #dce2ea; } .mode-tabs button { border:0; border-bottom:3px solid transparent; border-radius:0; } .mode-tabs .active { color:#2447b8; border-bottom-color:#365bd6; font-weight:700; } +.studio-grid { display:grid; grid-template-columns:minmax(250px,320px) minmax(0,1fr); gap:1rem; } +.outline,.editor,.conflict,.route-state { border:1px solid #dce2ea; border-radius:.8rem; background:#fff; padding:1rem; } .outline { align-self:start; position:sticky; top:1rem; max-height:calc(100vh - 2rem); overflow:auto; } .outline ol { list-style:none; padding-left:.5rem; } .outline > ol { padding:0; } .outline-item { display:block; width:100%; text-align:left; border-color:transparent; } .outline-item.selected { background:#eaf0ff; color:#173baf; } .outline-item.lesson { margin-left:.6rem; } .compact-actions { display:flex; gap:.25rem; flex-wrap:wrap; } .compact-actions button { min-height:36px; padding:.3rem .45rem; } +.editor { min-height:60vh; } .form-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem; } label { display:flex; flex-direction:column; gap:.35rem; font-weight:650; margin-bottom:1rem; } .wide { grid-column:1/-1; } input,textarea,select { width:100%; box-sizing:border-box; border:1px solid #9eabba; border-radius:.45rem; padding:.7rem; font:inherit; } textarea { resize:vertical; } +fieldset { border:0; padding:0; margin:0; min-width:0; } +.lesson-list { display:flex; gap:.4rem; overflow:auto; padding:.5rem 0 1rem; } .lesson-list .active { background:#eaf0ff; border-color:#365bd6; } .lesson-editor,.activity { border-top:1px solid #dce2ea; padding-top:1rem; margin-top:1rem; } .activity { background:#f8fafc; border:1px solid #dce2ea; border-radius:.6rem; padding:1rem; } +.quality-summary,dl div { display:flex; justify-content:space-between; gap:1rem; padding:.8rem; background:#f6f8fb; } .issues { list-style:none; padding:0; } .issues li { display:grid; grid-template-columns:auto 1fr auto; gap:.8rem; border-left:4px solid #d49c27; padding:.8rem; margin:.6rem 0; background:#fffbef; } .issues li.blocking { border-color:#bd3147; background:#fff3f5; } .issues p { margin:.25rem 0 0; } .success { padding:1rem; background:#eef9f1; border-radius:.5rem; } +.conflict { margin-bottom:1rem; background:#fff7df; } .conflict button { margin-right:.5rem; } .preview-card { max-width:760px; margin:auto; } dl { max-width:500px; } dt { font-weight:700; } .route-state { max-width:650px; margin:3rem auto; } .empty,.empty-state { color:#68758a; } +@media (max-width:800px) { .studio-grid { grid-template-columns:1fr; } .outline { position:static; max-height:none; } .form-grid { grid-template-columns:1fr; } .studio-header { align-items:flex-start; flex-direction:column; } } diff --git a/frontend/src/app/pages/course-studio/course-studio.component.spec.ts b/frontend/src/app/pages/course-studio/course-studio.component.spec.ts new file mode 100644 index 0000000..54d4aa3 --- /dev/null +++ b/frontend/src/app/pages/course-studio/course-studio.component.spec.ts @@ -0,0 +1,138 @@ +import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, Subject, throwError } from 'rxjs'; +import { CourseAuthoringCapabilities, CourseAuthoringDraft, emptyCourseAuthoringDraft } from '../../models/course-authoring.model'; +import { CoursesService } from '../../services/courses.service'; +import { CourseStudioComponent } from './course-studio.component'; + +describe('CourseStudioComponent', () => { + let courses: jasmine.SpyObj; + const capabilities: CourseAuthoringCapabilities = { + can_create: true, can_view_draft: true, can_edit: true, can_duplicate: true, + can_preview: true, can_submit_review: true, can_review: false, can_publish: false, + }; + + beforeEach(async () => { + courses = jasmine.createSpyObj('CoursesService', [ + 'getAuthoringCapabilities', 'getCourseTemplates', 'createAuthoringDraft', + 'saveAuthoringDraft', 'getAuthoringDraft', 'getCourseGovernanceSummary', + 'getAuthoringPreview', 'getCourseVersions', 'submitCourseForReview', + 'reviewCourse', 'publishCourseVersion', 'exportCourse', + ]); + courses.getAuthoringCapabilities.and.returnValue(of({ capabilities })); + courses.getCourseTemplates.and.returnValue(of([])); + await TestBed.configureTestingModule({ + imports: [CourseStudioComponent, RouterTestingModule], + providers: [ + { provide: CoursesService, useValue: courses }, + { provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => null } } } }, + ], + }).compileComponents(); + }); + + it('opens a blank studio only when the backend allows creation', () => { + const fixture = TestBed.createComponent(CourseStudioComponent); + fixture.detectChanges(); + expect(fixture.componentInstance.accessDenied).toBeFalse(); + expect(fixture.componentInstance.draft.capabilities?.can_create).toBeTrue(); + expect(fixture.nativeElement.querySelector('h2').textContent).toContain('Outline'); + }); + + it('uses one debounced idempotent create and keeps deterministic outline order', fakeAsync(() => { + spyOn(TestBed.inject(Router), 'navigate').and.resolveTo(true); + const fixture = TestBed.createComponent(CourseStudioComponent); + fixture.detectChanges(); + const component = fixture.componentInstance; + const saved: CourseAuthoringDraft = { ...emptyCourseAuthoringDraft(), id: 'course-1', title: 'History', description: 'Desc', revision_number: 1, revision_status: 'draft', updated_at: new Date().toISOString(), units: [], capabilities }; + courses.createAuthoringDraft.and.returnValue(of(saved)); + component.draft.title = 'History'; component.draft.description = 'Desc'; + component.addUnit(); component.addUnit(); component.moveUnit(1, -1); + tick(899); + expect(courses.createAuthoringDraft).not.toHaveBeenCalled(); + tick(1); + expect(courses.createAuthoringDraft).toHaveBeenCalledTimes(1); + expect(courses.createAuthoringDraft.calls.mostRecent().args[1]).toBeTruthy(); + })); + + it('retains local content and offers bounded recovery after a revision conflict', () => { + const fixture = TestBed.createComponent(CourseStudioComponent); + fixture.detectChanges(); + const component = fixture.componentInstance; + component.draft.title = 'Local work'; + component.saveState = 'conflict'; + component.conflict = { code: 'course_authoring_revision_conflict', course_id: 'course-1', current_revision: 3 }; + fixture.detectChanges(); + expect(component.draft.title).toBe('Local work'); + expect(fixture.nativeElement.textContent).toContain('changed elsewhere'); + }); + + it('serializes autosaves and queues edits made during an in-flight write', fakeAsync(() => { + spyOn(TestBed.inject(Router), 'navigate').and.resolveTo(true); + const fixture = TestBed.createComponent(CourseStudioComponent); + fixture.detectChanges(); + const component = fixture.componentInstance; + const firstWrite = new Subject(); + courses.createAuthoringDraft.and.returnValue(firstWrite); + courses.saveAuthoringDraft.and.returnValue(of({ ...emptyCourseAuthoringDraft(), id: 'course-1', title: 'Second edit', description: '', revision_number: 2, revision_status: 'draft', units: [], capabilities })); + component.draft.title = 'First edit'; component.changed(); tick(900); + expect(courses.createAuthoringDraft).toHaveBeenCalledTimes(1); + component.draft.title = 'Second edit'; component.changed(); tick(900); + expect(courses.saveAuthoringDraft).not.toHaveBeenCalled(); + firstWrite.next({ ...emptyCourseAuthoringDraft(), id: 'course-1', title: 'First edit', description: '', revision_number: 1, revision_status: 'draft', units: [], capabilities }); + firstWrite.complete(); tick(900); + expect(courses.saveAuthoringDraft).toHaveBeenCalledTimes(1); + expect(courses.saveAuthoringDraft.calls.mostRecent().args[1].title).toBe('Second edit'); + })); + + it('retries a failed create with the same idempotency key', fakeAsync(() => { + const fixture = TestBed.createComponent(CourseStudioComponent); + fixture.detectChanges(); + const component = fixture.componentInstance; + courses.createAuthoringDraft.and.returnValue(throwError(() => ({ status: 503 }))); + component.draft.title = 'Recoverable'; component.changed(); tick(900); + const firstKey = courses.createAuthoringDraft.calls.mostRecent().args[1]; + expect(component.saveState).toBe('failed'); + courses.createAuthoringDraft.and.returnValue(of({ ...emptyCourseAuthoringDraft(), id: 'course-1', title: 'Recoverable', description: '', revision_number: 1, revision_status: 'draft', units: [], capabilities })); + spyOn(TestBed.inject(Router), 'navigate').and.resolveTo(true); + component.retry(); + expect(courses.createAuthoringDraft.calls.mostRecent().args[1]).toBe(firstKey); + })); + + it('preserves local work and exposes a safe autosave support reference', fakeAsync(() => { + spyOn(console, 'warn'); + const fixture = TestBed.createComponent(CourseStudioComponent); + fixture.detectChanges(); + const component = fixture.componentInstance; + const failure = new HttpErrorResponse({ + status: 500, + headers: new HttpHeaders({ 'X-Request-ID': 'req-autosave' }), + error: { detail: 'internal database details' }, + }); + courses.createAuthoringDraft.and.returnValue(throwError(() => failure)); + component.draft.title = 'Work that must survive'; + component.changed(); + tick(900); + expect(component.draft.title).toBe('Work that must survive'); + expect(component.dirty).toBeTrue(); + expect(component.lifecycleMessage).toContain('Reference: req-autosave'); + expect(component.lifecycleMessage).not.toContain('database'); + })); + + it('reports publish failure safely without changing the draft lifecycle', () => { + spyOn(console, 'warn'); + const fixture = TestBed.createComponent(CourseStudioComponent); + fixture.detectChanges(); + const component = fixture.componentInstance; + component.draft.revision_status = 'approved'; + component.versions = [{ id: 'version-1', course_id: 'course-1', version_number: 1, status: 'draft', created_at: '' }]; + courses.publishCourseVersion.and.returnValue(throwError(() => new HttpErrorResponse({ + status: 500, + headers: new HttpHeaders({ 'X-Request-ID': 'req-publish' }), + }))); + component.publish(); + expect(component.draft.revision_status).toBe('approved'); + expect(component.lifecycleMessage).toContain('Reference: req-publish'); + }); +}); diff --git a/frontend/src/app/pages/course-studio/course-studio.component.ts b/frontend/src/app/pages/course-studio/course-studio.component.ts new file mode 100644 index 0000000..2d20d78 --- /dev/null +++ b/frontend/src/app/pages/course-studio/course-studio.component.ts @@ -0,0 +1,340 @@ +import { CommonModule } from '@angular/common'; +import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { Subject, Subscription, debounceTime, finalize, takeUntil } from 'rxjs'; +import { + AuthoringActivity, + AuthoringLesson, + AuthoringUnit, + CourseAuthoringConflict, + CourseAuthoringDraft, + CourseAuthoringValidationIssue, + CourseSaveState, + CourseStudioMode, + CourseVersionSummary, + CourseTemplate, + emptyCourseAuthoringDraft, +} from '../../models/course-authoring.model'; +import { CourseGovernanceSummary } from '../../models/course-publish-readiness.model'; +import { CoursesService } from '../../services/courses.service'; +import { Course } from '../../models/course'; +import { securityErrorMessage } from '../../services/security-error'; +import { DiagnosticService } from '../../services/diagnostic.service'; + +@Component({ + selector: 'app-course-studio', + standalone: true, + imports: [CommonModule, FormsModule, RouterLink], + templateUrl: './course-studio.component.html', + styleUrl: './course-studio.component.scss', +}) +export class CourseStudioComponent implements OnInit, OnDestroy { + readonly modes: Array<{ id: CourseStudioMode; label: string }> = [ + { id: 'setup', label: 'Setup' }, + { id: 'build', label: 'Build' }, + { id: 'quality', label: 'Quality' }, + { id: 'preview', label: 'Preview' }, + { id: 'release', label: 'Release' }, + ]; + readonly activityTypes = ['reading', 'video', 'discussion', 'quiz', 'assignment', 'storybook', 'coloring']; + + draft: CourseAuthoringDraft = emptyCourseAuthoringDraft(); + mode: CourseStudioMode = 'setup'; + saveState: CourseSaveState = 'idle'; + loading = true; + accessDenied = false; + notFound = false; + loadFailure = false; + savedAt?: Date; + conflict?: CourseAuthoringConflict; + localIssues: CourseAuthoringValidationIssue[] = []; + governance?: CourseGovernanceSummary; + learnerPreview?: Course; + versions: CourseVersionSummary[] = []; + templates: CourseTemplate[] = []; + lifecycleMessage = ''; + uploadMessage = ''; + private drag?: { kind: 'unit' | 'lesson' | 'activity'; index: number }; + selectedUnit = 0; + selectedLesson = 0; + dirty = false; + courseId?: string; + private readonly changes = new Subject(); + private readonly destroyed = new Subject(); + private saveSubscription?: Subscription; + private idempotencyKey = this.newKey(); + + constructor( + private readonly courses: CoursesService, + private readonly route: ActivatedRoute, + private readonly router: Router, + private readonly diagnostics: DiagnosticService, + ) {} + + ngOnInit(): void { + this.changes.pipe(debounceTime(900), takeUntil(this.destroyed)).subscribe(() => this.save()); + this.courseId = this.route.snapshot.paramMap.get('courseId') ?? undefined; + if (this.courseId) { + this.loadDraft(this.courseId); + } else { + this.courses.getAuthoringCapabilities().subscribe({ + next: ({ capabilities }) => { + this.loading = false; + this.accessDenied = !capabilities.can_create; + if (capabilities.can_create) { + this.draft.capabilities = capabilities; + this.courses.getCourseTemplates().subscribe({ next: templates => this.templates = templates }); + } + }, + error: () => { this.loading = false; this.loadFailure = true; }, + }); + } + } + + ngOnDestroy(): void { + this.destroyed.next(); + this.destroyed.complete(); + this.saveSubscription?.unsubscribe(); + } + + @HostListener('window:beforeunload', ['$event']) + warnBeforeUnload(event: BeforeUnloadEvent): void { + if (this.dirty) event.preventDefault(); + } + + selectMode(mode: CourseStudioMode): void { + this.mode = mode; + if (mode === 'quality') this.refreshQuality(); + if (mode === 'preview' && this.courseId) this.courses.getAuthoringPreview(this.courseId).subscribe({ next: preview => this.learnerPreview = preview }); + if (mode === 'release') this.refreshVersions(); + } + + changed(): void { + this.dirty = true; + this.saveState = navigator.onLine ? 'dirty' : 'offline'; + this.localIssues = this.validate(); + this.changes.next(); + } + + save(): void { + if (!this.dirty || this.saveState === 'saving' || this.accessDenied) return; + if (!navigator.onLine) { this.saveState = 'offline'; return; } + this.saveState = 'saving'; + const snapshot = structuredClone(this.draft); + const snapshotJson = JSON.stringify(snapshot); + const request = this.courseId + ? this.courses.saveAuthoringDraft(this.courseId, snapshot) + : this.courses.createAuthoringDraft(snapshot, this.idempotencyKey); + this.saveSubscription = request.pipe(finalize(() => this.saveSubscription = undefined)).subscribe({ + next: saved => { + const hadLaterChanges = JSON.stringify(this.draft) !== snapshotJson; + if (hadLaterChanges) { + this.draft.id = saved.id; + this.draft.revision_number = saved.revision_number; + this.draft.updated_at = saved.updated_at; + this.draft.current_version_id = saved.current_version_id; + this.draft.capabilities = saved.capabilities; + } else { + this.draft = saved; + } + this.courseId = saved.id; + this.dirty = hadLaterChanges; + this.saveState = hadLaterChanges ? 'dirty' : 'saved'; + this.savedAt = new Date(); + this.conflict = undefined; + if (saved.id && this.route.snapshot.paramMap.get('courseId') !== saved.id) { + void this.router.navigate(['/workspace/product-studio/courses', saved.id], { replaceUrl: true }); + } + if (hadLaterChanges) this.changes.next(); + }, + error: error => { + if (error.status === 409) { + this.saveState = 'conflict'; + this.conflict = error.error?.detail ?? error.error; + } else if (error.status === 422) { + this.saveState = 'failed'; + this.localIssues = error.error?.detail?.issues ?? error.error?.issues ?? this.validate(); + } else { + this.saveState = navigator.onLine ? 'failed' : 'offline'; + this.diagnostics.record('course_studio.autosave', error); + this.lifecycleMessage = this.diagnostics.message('Autosave failed. Your unsaved work remains in this browser.', error); + } + }, + }); + } + + retry(): void { this.dirty = true; this.save(); } + reloadServer(): void { if (this.courseId) this.loadDraft(this.courseId); } + preserveLocalCopy(): void { + this.courseId = undefined; + this.draft.id = undefined; + this.draft.revision_number = undefined; + this.draft.title = `${this.draft.title || 'Untitled course'} (recovered copy)`; + this.idempotencyKey = this.newKey(); + this.conflict = undefined; + this.dirty = true; + this.save(); + } + + addUnit(): void { + this.draft.units.push({ title: 'Untitled unit', content: '', lessons: [] }); + this.selectedUnit = this.draft.units.length - 1; this.selectedLesson = 0; this.mode = 'build'; this.changed(); + } + + applyTemplate(templateId: string): void { + if (!templateId) return; + const template = this.templates.find(item => item.id === templateId); + if (!template) return; + const capabilities = this.draft.capabilities; + this.draft = { ...emptyCourseAuthoringDraft(), ...structuredClone(template.course), template_id: template.id, capabilities } as CourseAuthoringDraft; + this.changed(); + } + + exportCourse(): void { + if (!this.courseId) return; + this.courses.exportCourse(this.courseId).subscribe(document => { + const url = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: 'application/json' })); + const link = globalThis.document.createElement('a'); + link.href = url; link.download = `${this.draft.title || 'course'}.echoed.json`; link.click(); URL.revokeObjectURL(url); + }); + } + duplicateUnit(index: number): void { this.draft.units.splice(index + 1, 0, this.cleanUnit(this.draft.units[index])); this.changed(); } + removeUnit(index: number): void { if (confirm(`Remove “${this.draft.units[index].title}” and its lessons?`)) { this.draft.units.splice(index, 1); this.selectedUnit = Math.max(0, Math.min(this.selectedUnit, this.draft.units.length - 1)); this.changed(); } } + moveUnit(index: number, delta: number): void { this.move(this.draft.units, index, delta); } + + addLesson(unitIndex = this.selectedUnit): void { + const lesson: AuthoringLesson = { title: 'Untitled lesson', key_concepts: [], discussion_questions: [], skill_tags: [], standards_metadata: {}, activities: [], sources: [] }; + this.draft.units[unitIndex].lessons.push(lesson); this.selectedUnit = unitIndex; this.selectedLesson = this.draft.units[unitIndex].lessons.length - 1; this.changed(); + } + duplicateLesson(unitIndex: number, lessonIndex: number): void { const lessons = this.draft.units[unitIndex].lessons; lessons.splice(lessonIndex + 1, 0, this.cleanLesson(lessons[lessonIndex])); this.changed(); } + removeLesson(unitIndex: number, lessonIndex: number): void { const lesson = this.draft.units[unitIndex].lessons[lessonIndex]; if (confirm(`Remove “${lesson.title}” and its activities?`)) { this.draft.units[unitIndex].lessons.splice(lessonIndex, 1); this.selectedLesson = Math.max(0, this.selectedLesson - 1); this.changed(); } } + moveLesson(unitIndex: number, lessonIndex: number, delta: number): void { this.move(this.draft.units[unitIndex].lessons, lessonIndex, delta); } + + addActivity(lesson: AuthoringLesson): void { lesson.activities.push({ type: 'reading', title: 'Untitled activity', content: '', pages: [] }); this.changed(); } + duplicateActivity(lesson: AuthoringLesson, index: number): void { const copy = structuredClone(lesson.activities[index]); this.stripIds(copy); lesson.activities.splice(index + 1, 0, copy); this.changed(); } + removeActivity(lesson: AuthoringLesson, index: number): void { if (confirm(`Remove “${lesson.activities[index].title}”?`)) { lesson.activities.splice(index, 1); this.changed(); } } + moveActivity(lesson: AuthoringLesson, index: number, delta: number): void { this.move(lesson.activities, index, delta); } + addSource(lesson: AuthoringLesson): void { lesson.sources.push({ citation: '', url: null }); this.changed(); } + uploadActivityAsset(event: Event, activity: AuthoringActivity): void { + const file = (event.target as HTMLInputElement).files?.[0]; + if (!file) return; + this.uploadMessage = `Uploading ${file.name}…`; + const upload = activity.type === 'storybook' + ? this.courses.uploadStorybookPage(file) + : this.courses.uploadColoring(file); + upload.subscribe({ + next: result => { + if (activity.type === 'storybook') activity.pages.push({ image_url: result.file_path }); + else activity.content = result.file_path; + this.uploadMessage = `${file.name} uploaded.`; + this.changed(); + }, + error: error => this.uploadMessage = securityErrorMessage(error, `Upload failed for ${file.name}.`), + }); + } + + activeUnit(): AuthoringUnit | undefined { return this.draft.units[this.selectedUnit]; } + activeLesson(): AuthoringLesson | undefined { return this.activeUnit()?.lessons[this.selectedLesson]; } + trim(value: string): string { return value.trim(); } + blocking(issue: CourseAuthoringValidationIssue): boolean { return issue.severity === 'blocking'; } + setAssessmentIds(owner: { assessment_ids?: string[] }, value: string): void { owner.assessment_ids = value.split(',').map(item => item.trim()).filter(Boolean); this.changed(); } + startDrag(kind: 'unit' | 'lesson' | 'activity', index: number, event: DragEvent): void { event.stopPropagation(); this.drag = { kind, index }; event.dataTransfer?.setData('text/plain', `${kind}:${index}`); } + dropUnit(index: number, event: DragEvent): void { event.preventDefault(); if (this.drag?.kind === 'unit') this.reorder(this.draft.units, this.drag.index, index); } + dropLesson(unit: AuthoringUnit, index: number, event: DragEvent): void { event.preventDefault(); if (this.drag?.kind === 'lesson') this.reorder(unit.lessons, this.drag.index, index); } + dropActivity(lesson: AuthoringLesson, index: number, event: DragEvent): void { event.preventDefault(); if (this.drag?.kind === 'activity') this.reorder(lesson.activities, this.drag.index, index); } + + focusIssue(issue: CourseAuthoringValidationIssue): void { + this.mode = issue.entity_type === 'course' ? 'setup' : 'build'; + setTimeout(() => { + const exact = document.getElementById(issue.entity_id ? `entity-${issue.entity_id}` : `field-${issue.field}`); + (exact ?? document.querySelector(`[data-authoring-field="${issue.field}"]`))?.focus(); + }); + } + + refreshQuality(): void { + this.localIssues = this.validate(); + if (this.courseId) this.courses.getCourseGovernanceSummary(this.courseId).subscribe({ next: value => this.governance = value }); + } + + submitForReview(): void { + if (!this.courseId || this.dirty) return; + this.courses.submitCourseForReview(this.courseId).subscribe({ + next: result => { this.draft.revision_status = result.lifecycle_state; this.lifecycleMessage = 'Submitted for independent review.'; }, + error: error => { this.lifecycleMessage = error.error?.detail?.code === 'course_not_ready_for_review' ? 'Complete the required course structure before review.' : 'Submission failed. Your draft was not changed.'; }, + }); + } + + recordReview(): void { + if (!this.courseId) return; + const feedback = prompt('Enter review feedback for the creator:'); + if (!feedback?.trim()) return; + const approved = confirm('Approve this course? Select Cancel to return it for changes.'); + this.courses.reviewCourse(this.courseId, approved ? 'approved' : 'changes_requested', feedback.trim()).subscribe({ + next: result => { this.draft.revision_status = result.lifecycle_state; this.lifecycleMessage = approved ? 'Course approved.' : 'Course returned for changes.'; }, + error: () => this.lifecycleMessage = 'Review decision could not be saved.', + }); + } + + publish(): void { + const draftVersion = this.versions.find(version => version.status === 'draft'); + if (!draftVersion) { this.lifecycleMessage = 'No draft version is available to publish.'; return; } + this.courses.publishCourseVersion(draftVersion.id).subscribe({ + next: () => { this.draft.revision_status = 'published'; this.lifecycleMessage = 'The approved version is published.'; this.refreshVersions(); }, + error: error => { + if (error.status === 422) { + this.lifecycleMessage = 'Safe-publish checks found blocking issues.'; + } else { + this.diagnostics.record('course_studio.publish', error); + this.lifecycleMessage = this.diagnostics.message('Publication failed without changing learner availability.', error); + } + }, + }); + } + + private refreshVersions(): void { + if (this.courseId) this.courses.getCourseVersions(this.courseId).subscribe({ next: versions => this.versions = versions }); + } + + private loadDraft(courseId: string): void { + this.loading = true; this.notFound = false; this.loadFailure = false; this.accessDenied = false; + this.courses.getAuthoringDraft(courseId).subscribe({ + next: draft => { this.draft = draft; this.loading = false; this.dirty = false; this.saveState = 'idle'; this.localIssues = this.validate(); }, + error: error => { this.loading = false; this.accessDenied = error.status === 403; this.notFound = error.status === 404; this.loadFailure = !this.accessDenied && !this.notFound; }, + }); + } + + private validate(): CourseAuthoringValidationIssue[] { + const issues: CourseAuthoringValidationIssue[] = []; + const add = (severity: 'blocking' | 'warning' | 'recommendation', entity_type: string, field: string, message: string, corrective_context: string, entity_id?: string) => issues.push({ severity, entity_type, entity_id, field, message, corrective_context }); + if (!this.draft.title.trim()) add('blocking', 'course', 'title', 'Add a course title.', 'Use a clear learner-facing title.'); + if (!this.draft.description.trim()) add('warning', 'course', 'description', 'Add a course description.', 'Explain what learners will know or do.'); + if (!this.draft.learning_objectives?.trim()) add('warning', 'course', 'learning_objectives', 'Add measurable learning objectives.', 'Start objectives with observable actions.'); + if (!this.draft.units.length) add('blocking', 'course', 'units', 'Add at least one unit.', 'Use Build to create the course outline.'); + this.draft.units.forEach((unit, ui) => { + if (!unit.title.trim()) add('blocking', 'unit', 'title', `Unit ${ui + 1} needs a title.`, 'Name the unit for its central concept.', unit.id); + if (!unit.lessons.length) add('blocking', 'unit', 'lessons', `“${unit.title}” needs a lesson.`, 'Add at least one lesson.', unit.id); + unit.lessons.forEach((lesson, li) => { + if (!lesson.title.trim()) add('blocking', 'lesson', 'title', `Lesson ${li + 1} needs a title.`, 'Name the lesson for its learning goal.', lesson.id); + if (!lesson.objective?.trim() && !lesson.learning_objectives?.trim()) add('warning', 'lesson', 'objective', `“${lesson.title}” needs an objective.`, 'Describe an observable learner outcome.', lesson.id); + lesson.activities.forEach(activity => { + if (!activity.title.trim()) add('blocking', 'activity', 'title', 'An activity needs a title.', 'Add a brief learner-facing title.', activity.id); + if (!activity.content.trim()) add('warning', 'activity', 'content', `“${activity.title}” has no instructions.`, 'Add accessible text instructions or a transcript.', activity.id); + if (activity.type === 'storybook' && !activity.pages.length) add('warning', 'activity', 'pages', `“${activity.title}” has no storybook pages.`, 'Upload at least one page and provide accessible instructions.', activity.id); + }); + if (!lesson.assessment?.trim() && !lesson.assessment_ids?.length) add('warning', 'lesson', 'assessment_ids', `“${lesson.title}” has no assessment evidence.`, 'Add a check for understanding or reference an assessment.', lesson.id); + lesson.sources.forEach(source => { + if (!source.citation.trim()) add('blocking', 'source', 'citation', 'A source is missing its citation.', 'Add enough attribution for a reviewer to identify the source.', source.id); + }); + }); + }); + return issues; + } + + private move(items: T[], index: number, delta: number): void { this.reorder(items, index, index + delta); } + private reorder(items: T[], from: number, to: number): void { if (to < 0 || to >= items.length || from === to) return; const [item] = items.splice(from, 1); items.splice(to, 0, item); this.drag = undefined; this.changed(); } + private cleanUnit(unit: AuthoringUnit): AuthoringUnit { const copy = structuredClone(unit); this.stripIds(copy); copy.title += ' (copy)'; return copy; } + private cleanLesson(lesson: AuthoringLesson): AuthoringLesson { const copy = structuredClone(lesson); this.stripIds(copy); copy.title += ' (copy)'; return copy; } + private stripIds(value: unknown): void { if (!value || typeof value !== 'object') return; if ('id' in value) delete (value as { id?: string }).id; Object.values(value).forEach(child => Array.isArray(child) ? child.forEach(item => this.stripIds(item)) : this.stripIds(child)); } + private newKey(): string { return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`; } +} diff --git a/frontend/src/app/pages/login/login.component.ts b/frontend/src/app/pages/login/login.component.ts index bf60979..ea39cfd 100644 --- a/frontend/src/app/pages/login/login.component.ts +++ b/frontend/src/app/pages/login/login.component.ts @@ -6,6 +6,7 @@ import { firstValueFrom } from 'rxjs'; import { AuthService } from '../../services/auth.service'; import { PermissionsService } from '../../services/permissions.service'; import { ShellNavigationService } from '../../services/shell-navigation.service'; +import { securityErrorMessage } from '../../services/security-error'; @Component({ selector: 'echo-login', @@ -61,7 +62,7 @@ export class LoginComponent { return; } } catch (error: any) { - this.errorMessage = error?.error?.detail || error?.message || 'Unable to login. Please check your credentials.'; + this.errorMessage = securityErrorMessage(error, 'Unable to login. Please check your credentials.'); } finally { this.isSubmitting = false; } diff --git a/frontend/src/app/pages/org-invites/org-invites.component.html b/frontend/src/app/pages/org-invites/org-invites.component.html index d9e2a49..0015f86 100644 --- a/frontend/src/app/pages/org-invites/org-invites.component.html +++ b/frontend/src/app/pages/org-invites/org-invites.component.html @@ -27,7 +27,7 @@

Active invites

{{ invite.email }}

Role: {{ invite.role | titlecase }} · Expires {{ invite.expires_at | date }}

- Token: {{ invite.token }} + Invitation pending diff --git a/frontend/src/app/pages/organization-invitations/organization-invitations.component.spec.ts b/frontend/src/app/pages/organization-invitations/organization-invitations.component.spec.ts index cb8e1ed..9d44d02 100644 --- a/frontend/src/app/pages/organization-invitations/organization-invitations.component.spec.ts +++ b/frontend/src/app/pages/organization-invitations/organization-invitations.component.spec.ts @@ -39,7 +39,7 @@ describe('OrganizationInvitationsComponent', () => { }); it('preserves form input when invitation creation fails', () => { - invites.createInvite.and.returnValue(throwError(() => ({ error: { detail: 'Invite failed' } }))); + invites.createInvite.and.returnValue(throwError(() => ({ status: 422, error: { detail: 'Invite failed' } }))); fixture.detectChanges(); const component = fixture.componentInstance; component.email = 'retry@example.com'; diff --git a/frontend/src/app/pages/organization-invitations/organization-invitations.component.ts b/frontend/src/app/pages/organization-invitations/organization-invitations.component.ts index a75ba2c..29e0537 100644 --- a/frontend/src/app/pages/organization-invitations/organization-invitations.component.ts +++ b/frontend/src/app/pages/organization-invitations/organization-invitations.component.ts @@ -9,6 +9,7 @@ import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo- import { OrganizationInvite } from '../../models/organization'; import { InvitesService } from '../../services/invites.service'; import { OrganizationService } from '../../services/organization.service'; +import { securityErrorMessage } from '../../services/security-error'; @Component({ selector: 'app-organization-invitations', @@ -86,7 +87,7 @@ export class OrganizationInvitationsComponent implements OnInit { this.confirmationOpen = false; }, error: error => { - this.formError = error?.error?.detail || 'We could not create this invitation. No membership was added.'; + this.formError = securityErrorMessage(error, 'We could not create this invitation. No membership was added.'); }, }); } diff --git a/frontend/src/app/pages/registration/registration.component.ts b/frontend/src/app/pages/registration/registration.component.ts index 1503f3e..1df7be6 100644 --- a/frontend/src/app/pages/registration/registration.component.ts +++ b/frontend/src/app/pages/registration/registration.component.ts @@ -6,6 +6,7 @@ import { Router, RouterModule } from '@angular/router'; import { RegisterDto } from '../../models/register-dto'; import { MetaService, EnumOption } from '../../services/meta.service'; import { finalize } from 'rxjs'; +import { securityErrorMessage } from '../../services/security-error'; import { normalizePendingOrganizationSetup, writePendingOrganizationSetup, @@ -140,9 +141,10 @@ export class RegistrationComponent implements OnInit { this.router.navigate(['/login']); }, error: (error) => { - this.errorMessage = - error?.error?.detail || - 'Registration failed. Please check your details and try again.'; + this.errorMessage = securityErrorMessage( + error, + 'Registration failed. Please check your details and try again.', + ); }, }); } diff --git a/frontend/src/app/pages/studio-courses/studio-courses.component.html b/frontend/src/app/pages/studio-courses/studio-courses.component.html index 5b30f4b..4f8e765 100644 --- a/frontend/src/app/pages/studio-courses/studio-courses.component.html +++ b/frontend/src/app/pages/studio-courses/studio-courses.component.html @@ -4,13 +4,12 @@

Product studio

Create course-backed product drafts and manage published learning paths.

-
-

New product draft

-
- - - +
+
+

Create a course

+

Build setup, units, lessons, activities, quality checks, and release readiness in one studio.

+
@@ -18,14 +17,16 @@

New product draft

Product library

Drafts + published
-
No products yet. Start with a draft above.
+ +
No products yet. Start with a draft above.

{{ course.title }}

{{ course.description }}

Draft - + Open studio +
diff --git a/frontend/src/app/pages/studio-courses/studio-courses.component.ts b/frontend/src/app/pages/studio-courses/studio-courses.component.ts index 1275a6f..dc27670 100644 --- a/frontend/src/app/pages/studio-courses/studio-courses.component.ts +++ b/frontend/src/app/pages/studio-courses/studio-courses.component.ts @@ -1,41 +1,47 @@ import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; import { CoursesService } from '../../services/courses.service'; import { Course } from '../../models/course'; -import { ToastService } from '../../services/toast.service'; +import { Router, RouterLink } from '@angular/router'; +import { CourseAuthoringCapabilities } from '../../models/course-authoring.model'; @Component({ selector: 'app-studio-courses', standalone: true, - imports: [CommonModule, FormsModule], + imports: [CommonModule, RouterLink], templateUrl: './studio-courses.component.html', styleUrl: './studio-courses.component.scss' }) export class StudioCoursesComponent implements OnInit { courses: Course[] = []; - title = ''; - description = ''; + capabilities?: CourseAuthoringCapabilities; + loadFailed = false; - constructor(private coursesService: CoursesService, private toastService: ToastService) {} + constructor( + private coursesService: CoursesService, + private router: Router, + ) {} ngOnInit(): void { this.coursesService.getCourses().subscribe(courses => { this.courses = courses; }); + this.coursesService.getAuthoringCapabilities().subscribe({ + next: response => this.capabilities = response.capabilities, + error: () => this.loadFailed = true, + }); } createCourse(): void { - if (!this.title || !this.description) { - return; - } - this.coursesService.createCourse({ title: this.title, description: this.description, units: [] }).subscribe(() => { - this.toastService.show('Draft course created.', 'success'); - this.coursesService.getCourses().subscribe(courses => { - this.courses = courses; - this.title = ''; - this.description = ''; - }); + this.router.navigate(['/workspace/product-studio/courses/new']); + } + + duplicateCourse(course: Course): void { + const title = prompt('Title for the duplicated course:', `${course.title} (copy)`); + if (!title?.trim()) return; + const key = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`; + this.coursesService.duplicateAuthoringCourse(course.id, title.trim(), key).subscribe(copy => { + void this.router.navigate(['/workspace/product-studio/courses', copy.id]); }); } } diff --git a/frontend/src/app/routing/navigation-error.handler.spec.ts b/frontend/src/app/routing/navigation-error.handler.spec.ts index c9e54ec..e4cde16 100644 --- a/frontend/src/app/routing/navigation-error.handler.spec.ts +++ b/frontend/src/app/routing/navigation-error.handler.spec.ts @@ -1,5 +1,7 @@ -import { NavigationError } from '@angular/router'; -import { isLazyChunkLoadError } from './navigation-error.handler'; +import { TestBed } from '@angular/core/testing'; +import { NavigationError, Router } from '@angular/router'; +import { DiagnosticService } from '../services/diagnostic.service'; +import { isLazyChunkLoadError, recoverFromNavigationError } from './navigation-error.handler'; describe('navigation error recovery', () => { it('recognizes browser dynamic-import failures', () => { @@ -23,4 +25,19 @@ describe('navigation error recovery', () => { expect(isLazyChunkLoadError(error)).toBeFalse(); }); + + it('records a bounded lazy-chunk category before redirecting to recovery', () => { + const diagnostics = jasmine.createSpyObj('DiagnosticService', ['record']); + const router = jasmine.createSpyObj('Router', ['parseUrl']); + router.parseUrl.and.returnValue({} as never); + TestBed.configureTestingModule({ providers: [ + { provide: DiagnosticService, useValue: diagnostics }, + { provide: Router, useValue: router }, + ] }); + const cause = new Error('Loading chunk 42 failed'); + const error = new NavigationError(4, '/studio', cause); + const result = TestBed.runInInjectionContext(() => recoverFromNavigationError(error)); + expect(result).toBeDefined(); + expect(diagnostics.record).toHaveBeenCalledWith('navigation.lazy_chunk', cause); + }); }); diff --git a/frontend/src/app/routing/navigation-error.handler.ts b/frontend/src/app/routing/navigation-error.handler.ts index c1962d4..e12afbb 100644 --- a/frontend/src/app/routing/navigation-error.handler.ts +++ b/frontend/src/app/routing/navigation-error.handler.ts @@ -1,5 +1,6 @@ import { inject } from '@angular/core'; import { NavigationError, RedirectCommand, Router } from '@angular/router'; +import { DiagnosticService } from '../services/diagnostic.service'; const LAZY_CHUNK_ERROR_PATTERNS = [ /ChunkLoadError/i, @@ -28,6 +29,7 @@ export function recoverFromNavigationError(error: NavigationError): RedirectComm } const router = inject(Router); + inject(DiagnosticService).record('navigation.lazy_chunk', error.error); return new RedirectCommand(router.parseUrl('/load-error'), { replaceUrl: true, state: { retryUrl: error.url }, diff --git a/frontend/src/app/services/auth.interceptor.spec.ts b/frontend/src/app/services/auth.interceptor.spec.ts index c556c58..79f5687 100644 --- a/frontend/src/app/services/auth.interceptor.spec.ts +++ b/frontend/src/app/services/auth.interceptor.spec.ts @@ -12,7 +12,7 @@ describe('authInterceptor', () => { let authService: jasmine.SpyObj; beforeEach(() => { - authService = jasmine.createSpyObj('AuthService', ['getToken']); + authService = jasmine.createSpyObj('AuthService', ['getToken', 'logout']); TestBed.configureTestingModule({ providers: [ @@ -49,4 +49,11 @@ describe('authInterceptor', () => { expect(req.request.headers.has('Authorization')).toBeFalse(); req.flush({}); }); + + it('clears an expired authenticated session after a 401 response', () => { + authService.getToken.and.returnValue('expired-token'); + http.get('/api/protected').subscribe({ error: () => undefined }); + httpMock.expectOne('/api/protected').flush({}, { status: 401, statusText: 'Unauthorized' }); + expect(authService.logout).toHaveBeenCalled(); + }); }); diff --git a/frontend/src/app/services/auth.interceptor.ts b/frontend/src/app/services/auth.interceptor.ts index fbe9a1d..c95673a 100644 --- a/frontend/src/app/services/auth.interceptor.ts +++ b/frontend/src/app/services/auth.interceptor.ts @@ -1,6 +1,7 @@ import { inject } from '@angular/core'; import { HttpInterceptorFn } from '@angular/common/http'; import { AuthService } from './auth.service'; +import { catchError, throwError } from 'rxjs'; export const authInterceptor: HttpInterceptorFn = (req, next) => { const authService = inject(AuthService); @@ -11,7 +12,10 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { Authorization: `Bearer ${token}` } }); - return next(authReq); + return next(authReq).pipe(catchError(error => { + if (error.status === 401) authService.logout(); + return throwError(() => error); + })); } return next(req); }; diff --git a/frontend/src/app/services/courses.service.spec.ts b/frontend/src/app/services/courses.service.spec.ts index 61415e0..7a8170a 100644 --- a/frontend/src/app/services/courses.service.spec.ts +++ b/frontend/src/app/services/courses.service.spec.ts @@ -24,6 +24,22 @@ describe('CoursesService', () => { expect(service).toBeTruthy(); }); + it('creates an authoring draft with an idempotency key', () => { + service.createAuthoringDraft({ title: 'Course', description: '', default_locale: 'en', skill_tags: [], standards_metadata: {}, units: [] }, 'retry-safe-key').subscribe(); + const request = httpMock.expectOne(`${environment.apiUrl}/api/courses/authoring`); + expect(request.request.method).toBe('POST'); + expect(request.request.headers.get('Idempotency-Key')).toBe('retry-safe-key'); + request.flush({}); + }); + + it('sends the optimistic revision in an aggregate save', () => { + service.saveAuthoringDraft('course-1', { title: 'Course', description: '', default_locale: 'en', skill_tags: [], standards_metadata: {}, units: [], revision_number: 7 }).subscribe(); + const request = httpMock.expectOne(`${environment.apiUrl}/api/courses/course-1/authoring-draft`); + expect(request.request.method).toBe('PUT'); + expect(request.request.body.revision_number).toBe(7); + request.flush({}); + }); + it('calls the publish-readiness endpoint for a course', () => { service.getCoursePublishReadiness('course-1').subscribe((response) => { expect(response.course_id).toBe('course-1'); diff --git a/frontend/src/app/services/courses.service.ts b/frontend/src/app/services/courses.service.ts index 584df00..44a7274 100644 --- a/frontend/src/app/services/courses.service.ts +++ b/frontend/src/app/services/courses.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Observable, of } from 'rxjs'; import { environment } from '../../environments/environment'; import { Course } from '../models/course'; @@ -16,6 +16,13 @@ import { CourseRuntimeInterventionRecommendation, CourseSafePublishValidation, } from '../models/course-publish-readiness.model'; +import { + CourseAuthoringCapabilityEnvelope, + CourseAuthoringDraft, + CourseLifecycleResponse, + CourseVersionSummary, + CourseTemplate, +} from '../models/course-authoring.model'; import { map, catchError } from 'rxjs/operators'; @Injectable({ @@ -107,6 +114,79 @@ export class CoursesService { return this.http.post(`${this.apiUrl}`, course); } + getAuthoringCapabilities(): Observable { + return this.http.get( + `${environment.apiUrl}/api/course-authoring/capabilities`, + ); + } + + getCourseAuthoringCapabilities(courseId: string): Observable { + return this.http.get( + `${this.apiUrl}/${courseId}/authoring-capabilities`, + ); + } + + createAuthoringDraft( + draft: CourseAuthoringDraft, + idempotencyKey: string, + ): Observable { + return this.http.post(`${this.apiUrl}/authoring`, draft, { + headers: new HttpHeaders({ 'Idempotency-Key': idempotencyKey }), + }); + } + + getAuthoringDraft(courseId: string): Observable { + return this.http.get(`${this.apiUrl}/${courseId}/authoring-draft`); + } + + saveAuthoringDraft(courseId: string, draft: CourseAuthoringDraft): Observable { + return this.http.put(`${this.apiUrl}/${courseId}/authoring-draft`, draft); + } + + duplicateAuthoringCourse( + courseId: string, + title: string, + idempotencyKey: string, + ): Observable { + return this.http.post( + `${this.apiUrl}/${courseId}/duplicate`, + { title }, + { headers: new HttpHeaders({ 'Idempotency-Key': idempotencyKey }) }, + ); + } + + getAuthoringPreview(courseId: string): Observable { + return this.http.get(`${this.apiUrl}/${courseId}/authoring-preview`); + } + + submitCourseForReview(courseId: string): Observable { + return this.http.post(`${this.apiUrl}/${courseId}/submit-review`, {}); + } + + reviewCourse( + courseId: string, + decision: 'approved' | 'changes_requested', + feedback: string, + ): Observable { + return this.http.post(`${this.apiUrl}/${courseId}/review`, { decision, feedback }); + } + + getCourseVersions(courseId: string): Observable { + return this.http.get(`${this.apiUrl}/${courseId}/versions`); + } + + publishCourseVersion(versionId: string): Observable { + return this.http.post(`${environment.apiUrl}/api/course-versions/${versionId}/publish`, {}); + } + + getCourseTemplates(): Observable { + return this.http.get(`${environment.apiUrl}/api/course-authoring/templates`); + } + + exportCourse(courseId: string): Observable> { + return this.http.get>(`${this.apiUrl}/${courseId}/export`); + } + startCourse(request: StartCourseRequest): Observable { return this.http.post(this.startCourseUrl, request); } diff --git a/frontend/src/app/services/diagnostic.interceptor.spec.ts b/frontend/src/app/services/diagnostic.interceptor.spec.ts new file mode 100644 index 0000000..d7d7ea4 --- /dev/null +++ b/frontend/src/app/services/diagnostic.interceptor.spec.ts @@ -0,0 +1,26 @@ +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { HttpClient } from '@angular/common/http'; +import { diagnosticInterceptor } from './diagnostic.interceptor'; +import { DiagnosticService } from './diagnostic.service'; + +describe('diagnosticInterceptor', () => { + it('records unexpected API failures and leaves the error response intact', () => { + const diagnostics = jasmine.createSpyObj('DiagnosticService', ['record']); + TestBed.configureTestingModule({ providers: [ + provideHttpClient(withInterceptors([diagnosticInterceptor])), + provideHttpClientTesting(), + { provide: DiagnosticService, useValue: diagnostics }, + ] }); + const client = TestBed.inject(HttpClient); + const http = TestBed.inject(HttpTestingController); + let status = 0; + client.get('/api/failure').subscribe({ error: error => status = error.status }); + const request = http.expectOne('/api/failure'); + request.flush({ detail: 'must not be logged by the interceptor' }, { status: 500, statusText: 'Server Error' }); + expect(status).toBe(500); + expect(diagnostics.record).toHaveBeenCalledWith('api.request', jasmine.objectContaining({ status: 500 })); + http.verify(); + }); +}); diff --git a/frontend/src/app/services/diagnostic.interceptor.ts b/frontend/src/app/services/diagnostic.interceptor.ts new file mode 100644 index 0000000..11a30bc --- /dev/null +++ b/frontend/src/app/services/diagnostic.interceptor.ts @@ -0,0 +1,14 @@ +import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { catchError, throwError } from 'rxjs'; +import { DiagnosticService } from './diagnostic.service'; + +export const diagnosticInterceptor: HttpInterceptorFn = (request, next) => { + const diagnostics = inject(DiagnosticService); + return next(request).pipe( + catchError((error: HttpErrorResponse) => { + if (error.status === 0 || error.status >= 500) diagnostics.record('api.request', error); + return throwError(() => error); + }), + ); +}; diff --git a/frontend/src/app/services/diagnostic.service.spec.ts b/frontend/src/app/services/diagnostic.service.spec.ts new file mode 100644 index 0000000..3998a0d --- /dev/null +++ b/frontend/src/app/services/diagnostic.service.spec.ts @@ -0,0 +1,37 @@ +import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; +import { DiagnosticService } from './diagnostic.service'; + +describe('DiagnosticService', () => { + let service: DiagnosticService; + + beforeEach(() => service = new DiagnosticService()); + + it('extracts only bounded safe backend request references', () => { + const safe = new HttpErrorResponse({ status: 500, headers: new HttpHeaders({ 'X-Request-ID': 'req-ABC_123' }) }); + const unsafe = new HttpErrorResponse({ status: 500, headers: new HttpHeaders({ 'X-Request-ID': 'bad reference with spaces' }) }); + expect(service.reference(safe)).toBe('req-ABC_123'); + expect(service.reference(unsafe)).toBeUndefined(); + }); + + it('records bounded metadata without response bodies, secrets, or learner content', () => { + const warning = spyOn(console, 'warn'); + const error = new HttpErrorResponse({ + status: 500, + headers: new HttpHeaders({ 'X-Request-ID': 'req-42' }), + error: { password: 'never-log-this', lesson_content: 'private learner text' }, + url: '/api/login?token=secret', + }); + service.record('Course Studio autosave', error); + expect(warning).toHaveBeenCalledWith('EchoEd diagnostic', { + event: 'frontend.operation.failed', operation: 'course_studio_autosave', status: 500, requestId: 'req-42', + }); + expect(JSON.stringify(warning.calls.mostRecent().args)).not.toContain('never-log-this'); + expect(JSON.stringify(warning.calls.mostRecent().args)).not.toContain('private learner text'); + }); + + it('adds support references only to unexpected server failures', () => { + const headers = new HttpHeaders({ 'X-Request-ID': 'req-support' }); + expect(service.message('Try again.', new HttpErrorResponse({ status: 500, headers }))).toContain('Reference: req-support'); + expect(service.message('Fix the form.', new HttpErrorResponse({ status: 422, headers }))).toBe('Fix the form.'); + }); +}); diff --git a/frontend/src/app/services/diagnostic.service.ts b/frontend/src/app/services/diagnostic.service.ts new file mode 100644 index 0000000..4fa1ee3 --- /dev/null +++ b/frontend/src/app/services/diagnostic.service.ts @@ -0,0 +1,47 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { requestReference, withSupportReference } from './security-error'; + +export interface SafeFrontendDiagnostic { + event: string; + operation: string; + status?: number; + requestId?: string; +} + +@Injectable({ providedIn: 'root' }) +export class DiagnosticService { + private readonly reported = new WeakSet(); + + reference(error: unknown): string | undefined { + return requestReference(error); + } + + message(message: string, error: unknown): string { + return withSupportReference(message, error); + } + + record(operation: string, error: unknown): void { + if (error && typeof error === 'object') { + if (this.reported.has(error)) return; + this.reported.add(error); + } + + const response = error as HttpErrorResponse | undefined; + const diagnostic: SafeFrontendDiagnostic = { + event: 'frontend.operation.failed', + operation: this.safeOperation(operation), + }; + if (typeof response?.status === 'number') diagnostic.status = response.status; + const requestId = this.reference(error); + if (requestId) diagnostic.requestId = requestId; + + // Deliberately exclude the exception, response body, URL, headers, and form state. + console.warn('EchoEd diagnostic', diagnostic); + } + + private safeOperation(operation: string): string { + const normalized = operation.toLowerCase().replace(/[^a-z0-9._-]/g, '_').slice(0, 64); + return normalized || 'unknown'; + } +} diff --git a/frontend/src/app/services/global-error.handler.spec.ts b/frontend/src/app/services/global-error.handler.spec.ts new file mode 100644 index 0000000..0c4ca99 --- /dev/null +++ b/frontend/src/app/services/global-error.handler.spec.ts @@ -0,0 +1,12 @@ +import { GlobalErrorHandler } from './global-error.handler'; +import { DiagnosticService } from './diagnostic.service'; + +describe('GlobalErrorHandler', () => { + it('routes unhandled failures through the safe diagnostic service', () => { + const diagnostics = jasmine.createSpyObj('DiagnosticService', ['record']); + const handler = new GlobalErrorHandler(diagnostics); + const failure = new Error('private stack detail'); + handler.handleError(failure); + expect(diagnostics.record).toHaveBeenCalledWith('angular.unhandled', failure); + }); +}); diff --git a/frontend/src/app/services/global-error.handler.ts b/frontend/src/app/services/global-error.handler.ts new file mode 100644 index 0000000..e037957 --- /dev/null +++ b/frontend/src/app/services/global-error.handler.ts @@ -0,0 +1,11 @@ +import { ErrorHandler, Injectable } from '@angular/core'; +import { DiagnosticService } from './diagnostic.service'; + +@Injectable() +export class GlobalErrorHandler implements ErrorHandler { + constructor(private readonly diagnostics: DiagnosticService) {} + + handleError(error: unknown): void { + this.diagnostics.record('angular.unhandled', error); + } +} diff --git a/frontend/src/app/services/security-error.spec.ts b/frontend/src/app/services/security-error.spec.ts new file mode 100644 index 0000000..a769fa9 --- /dev/null +++ b/frontend/src/app/services/security-error.spec.ts @@ -0,0 +1,22 @@ +import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; + +import { securityErrorMessage } from './security-error'; + +describe('securityErrorMessage', () => { + it('maps authentication, permission, concealed, and rate-limit responses safely', () => { + expect(securityErrorMessage(new HttpErrorResponse({ status: 401 }), 'fallback')).toContain('Sign in'); + expect(securityErrorMessage(new HttpErrorResponse({ status: 403 }), 'fallback')).toContain('permission'); + expect(securityErrorMessage(new HttpErrorResponse({ status: 404 }), 'fallback')).toContain('unavailable'); + expect(securityErrorMessage(new HttpErrorResponse({ status: 429, headers: new HttpHeaders({ 'Retry-After': '30' }) }), 'fallback')).toContain('Too many'); + }); + + it('preserves an actionable administrator conflict without exposing unknown errors', () => { + expect(securityErrorMessage(new HttpErrorResponse({ status: 409, error: { detail: 'This action would remove the final platform super administrator.' } }), 'fallback')).toContain('final platform'); + expect(securityErrorMessage(new Error('internal database details'), 'Safe fallback')).toBe('Safe fallback'); + }); + + it('adds a safe request reference to unexpected server errors', () => { + const error = new HttpErrorResponse({ status: 500, headers: new HttpHeaders({ 'X-Request-ID': 'req-500' }) }); + expect(securityErrorMessage(error, 'Something went wrong.')).toBe('Something went wrong. Reference: req-500'); + }); +}); diff --git a/frontend/src/app/services/security-error.ts b/frontend/src/app/services/security-error.ts new file mode 100644 index 0000000..917ef87 --- /dev/null +++ b/frontend/src/app/services/security-error.ts @@ -0,0 +1,35 @@ +import { HttpErrorResponse } from '@angular/common/http'; + +const SAFE_REFERENCE = /^[A-Za-z0-9._:-]{1,128}$/; + +export function requestReference(error: unknown): string | undefined { + const response = error as HttpErrorResponse | undefined; + const candidate = response?.headers?.get('X-Request-ID') ?? + (typeof response?.error?.request_id === 'string' ? response.error.request_id : undefined); + return candidate && SAFE_REFERENCE.test(candidate) ? candidate : undefined; +} + +export function withSupportReference(message: string, error: unknown): string { + const response = error as HttpErrorResponse | undefined; + const reference = response?.status && response.status >= 500 ? requestReference(error) : undefined; + return reference ? `${message} Reference: ${reference}` : message; +} + +export function securityErrorMessage(error: unknown, fallback: string): string { + const response = error as HttpErrorResponse | undefined; + switch (response?.status) { + case 401: + return 'Your session has expired or is invalid. Sign in and try again.'; + case 403: + return 'Your account does not have permission for this action.'; + case 404: + return 'This record is unavailable or you do not have access to it.'; + case 409: + case 422: + return typeof response.error?.detail === 'string' ? response.error.detail : fallback; + case 429: + return 'Too many attempts. Wait a moment, then try again.'; + default: + return withSupportReference(fallback, error); + } +} diff --git a/frontend/src/app/services/users.service.spec.ts b/frontend/src/app/services/users.service.spec.ts index db83d09..bbc7c43 100644 --- a/frontend/src/app/services/users.service.spec.ts +++ b/frontend/src/app/services/users.service.spec.ts @@ -1,19 +1,31 @@ -import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { UsersService } from './users.service'; +import { environment } from '../../environments/environment'; describe('UsersService', () => { let service: UsersService; + let httpMock: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule] }); service = TestBed.inject(UsersService); + httpMock = TestBed.inject(HttpTestingController); }); it('should be created', () => { expect(service).toBeTruthy(); }); + + it('sends only the allowlisted role field for a privileged role change', () => { + const user = { id: 'user-1', firstname: 'Ada', lastname: 'Lovelace', username: 'ada', email: 'ada@example.test', role: 'student', created_at: new Date() }; + service.updateUserRole(user, 'teacher').subscribe(); + const request = httpMock.expectOne(`${environment.apiUrl}/api/users/user-1`); + expect(request.request.body).toEqual({ role: 'teacher' }); + expect(request.request.body.password).toBeUndefined(); + request.flush({ message: 'ok' }); + }); }); diff --git a/frontend/src/app/services/users.service.ts b/frontend/src/app/services/users.service.ts index 1c4d6f1..0940bb4 100644 --- a/frontend/src/app/services/users.service.ts +++ b/frontend/src/app/services/users.service.ts @@ -23,14 +23,7 @@ export class UsersService { } updateUserRole(user: User, role: string): Observable<{ message: string }> { - return this.http.put<{ message: string }>(`${this.apiUrl}/${user.id}`, { - firstname: user.firstname, - lastname: user.lastname, - username: user.username, - email: user.email, - password: '', - role, - }); + return this.http.put<{ message: string }>(`${this.apiUrl}/${user.id}`, { role }); } deleteUser(userId: string): Observable { diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts index 5cf6c71..a4c3cc6 100644 --- a/frontend/src/environments/environment.prod.ts +++ b/frontend/src/environments/environment.prod.ts @@ -1,4 +1,5 @@ export const environment = { production: true, apiUrl: 'https://acceptable-enchantment-production-c407.up.railway.app', // Production backend URL + courseAuthoringStudioEnabled: true, }; diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index 9731ef1..4c04254 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -1,4 +1,5 @@ export const environment = { production: false, apiUrl: '', // Local backend URL + courseAuthoringStudioEnabled: true, }; diff --git a/frontend/tests/demo/admin-platform-smoke.spec.ts b/frontend/tests/demo/admin-platform-smoke.spec.ts index 2f62496..125d813 100644 --- a/frontend/tests/demo/admin-platform-smoke.spec.ts +++ b/frontend/tests/demo/admin-platform-smoke.spec.ts @@ -4,7 +4,7 @@ const ADMIN_USERNAME = process.env['DEMO_ADMIN_USERNAME'] || 'orgadmin'; const ADMIN_PASSWORD = process.env['DEMO_ADMIN_PASSWORD'] || 'password'; async function signIn(page: import('@playwright/test').Page, username: string, password: string) { - await page.goto('/login'); + await page.goto('/login', { waitUntil: 'domcontentloaded' }); await page.getByLabel('Email or Username').fill(username); await page.getByLabel('Password').fill(password); await page.getByRole('button', { name: 'Sign in' }).click(); diff --git a/frontend/tests/demo/course-authoring-studio.spec.ts b/frontend/tests/demo/course-authoring-studio.spec.ts new file mode 100644 index 0000000..cce99f5 --- /dev/null +++ b/frontend/tests/demo/course-authoring-studio.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from '@playwright/test'; + +const capabilities = { + can_create: true, can_view_draft: true, can_edit: true, can_duplicate: true, + can_preview: true, can_submit_review: true, can_review: false, can_publish: false, +}; + +test('course creator completes the canonical Studio journey at desktop and mobile sizes', async ({ page }) => { + page.on('pageerror', error => console.error(`PAGE ERROR: ${error.message}`)); + let revision = 1; + let latest: Record = {}; + await page.addInitScript(() => { + const payload = btoa(JSON.stringify({ sub: 'author-1', role: 'content_admin', exp: Math.floor(Date.now() / 1000) + 3600 })).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); + localStorage.setItem('auth_token', `header.${payload}.signature`); + localStorage.setItem('active_org_id', 'org-1'); + localStorage.setItem('active_org_role', 'content_admin'); + }); + await page.route('**/api/**', async route => { + const url = route.request().url(); + const method = route.request().method(); + const json = (body: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + if (url.endsWith('/api/orgs')) return json([{ id: 'org-1', name: 'Pilot School', type: 'school', role: 'content_admin' }]); + if (url.endsWith('/api/course-authoring/capabilities')) return json({ organization_id: 'org-1', capabilities }); + if (url.endsWith('/api/course-authoring/templates')) return json([]); + if (url.endsWith('/api/courses/authoring') && method === 'POST') { + latest = route.request().postDataJSON(); + return json({ ...latest, id: 'course-1', organization_id: 'org-1', created_by: 'author-1', revision_number: revision, revision_status: 'draft', revision_metadata: {}, updated_at: new Date().toISOString(), current_version_id: 'version-1', assessment_ids: [], capabilities }); + } + if (url.endsWith('/api/courses/course-1/authoring-draft') && method === 'GET') { + return json({ ...latest, id: 'course-1', organization_id: 'org-1', created_by: 'author-1', revision_number: revision, revision_status: 'draft', revision_metadata: {}, updated_at: new Date().toISOString(), current_version_id: 'version-1', assessment_ids: [], capabilities }); + } + if (url.endsWith('/api/courses/course-1/authoring-draft') && method === 'PUT') { + latest = route.request().postDataJSON(); revision += 1; + return json({ ...latest, id: 'course-1', organization_id: 'org-1', created_by: 'author-1', revision_number: revision, revision_status: 'draft', revision_metadata: {}, updated_at: new Date().toISOString(), current_version_id: 'version-1', assessment_ids: [], capabilities }); + } + if (url.endsWith('/api/courses/course-1/governance-summary')) return json({ course_id: 'course-1', course_title: 'Evidence Lab', publish_readiness: { blocking_issue_count: 0 }, safe_publish_validation: { blocking_issue_count: 0, blocking_issues: [], warnings: [] }, lineage_safety_visibility: {}, competency_evidence_integrity: {}, runtime_intervention_recommendations: [] }); + if (url.endsWith('/api/courses/course-1/authoring-preview')) return json({ ...latest, id: 'course-1', revision_number: revision, revision_status: 'draft', revision_metadata: {}, lineage_status: 'standalone', lineage_metadata: {}, skill_tags: [], standards_metadata: {} }); + if (url.endsWith('/api/courses/course-1/versions')) return json([{ id: 'version-1', course_id: 'course-1', version_number: 1, status: 'draft', created_at: new Date().toISOString() }]); + return json([]); + }); + + await page.goto('/workspace/product-studio/courses/new'); + await expect(page.getByRole('heading', { name: 'Untitled course' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Setup' })).toHaveAttribute('aria-selected', 'true'); + await page.getByLabel('Title', { exact: true }).fill('Evidence Lab'); + await page.getByLabel('Description', { exact: true }).fill('Practice evaluating primary sources.'); + await page.getByRole('button', { name: '+ Unit' }).click(); + await page.getByLabel('Unit title').fill('Source evaluation'); + await page.getByRole('button', { name: '+ Lesson' }).click(); + await page.getByLabel('Lesson title').fill('Claims and evidence'); + await page.getByRole('button', { name: '+ Activity' }).click(); + await page.getByLabel('Title', { exact: true }).last().fill('Compare two accounts'); + await page.getByLabel('Instructions / content').fill('Read both accounts and identify one supported claim.'); + await expect(page).toHaveURL(/\/courses\/course-1$/); + await expect(page.getByRole('status').first()).toContainText('Idle'); + + await page.getByRole('tab', { name: 'Quality' }).click(); + await expect(page.getByRole('heading', { name: 'Quality check' })).toBeVisible(); + await page.getByRole('tab', { name: 'Preview' }).click(); + await expect(page.getByRole('heading', { name: 'Learner preview' })).toBeVisible(); + await page.getByRole('tab', { name: 'Release' }).click(); + await expect(page.getByRole('heading', { name: 'Review and release' })).toBeVisible(); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.getByRole('heading', { name: 'Review and release' })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true); + await page.keyboard.press('Tab'); + await expect(page.locator(':focus')).toBeVisible(); +}); diff --git a/frontend/tests/demo/critical-accessibility.spec.ts b/frontend/tests/demo/critical-accessibility.spec.ts index bd6a9cf..39d14dc 100644 --- a/frontend/tests/demo/critical-accessibility.spec.ts +++ b/frontend/tests/demo/critical-accessibility.spec.ts @@ -15,7 +15,14 @@ async function expectSinglePageHeading(page: Page) { } async function expectVisibleKeyboardFocus(page: Page) { - await page.keyboard.press('Tab'); + for (let attempt = 0; attempt < 5; attempt += 1) { + await page.keyboard.press('Tab'); + const hasFocusedControl = await page.evaluate(() => { + const active = document.activeElement as HTMLElement | null; + return !!active && active !== document.body && active.getClientRects().length > 0; + }); + if (hasFocusedControl) break; + } const focused = page.locator(':focus'); await expect(focused).toBeVisible(); const hasFocusIndicator = await focused.evaluate(element => { diff --git a/frontend/tests/demo/security-hardening.spec.ts b/frontend/tests/demo/security-hardening.spec.ts new file mode 100644 index 0000000..282f5b1 --- /dev/null +++ b/frontend/tests/demo/security-hardening.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/test'; + +test.describe('platform security hardening', () => { + test('anonymous forum mutation fails closed at the API boundary', async ({ request }) => { + const response = await request.post('/api/forum/threads', { + data: { title: 'Anonymous topic', content: 'This must not be accepted.' }, + }); + + expect(response.status()).toBe(401); + }); + + test('rate limiting is announced accessibly without discarding credentials', async ({ page }) => { + await page.route('**/api/auth/token', async route => { + await route.fulfill({ + status: 429, + headers: { 'Content-Type': 'application/json', 'Retry-After': '60' }, + body: JSON.stringify({ detail: 'Too many attempts. Try again later.' }), + }); + }); + + await page.goto('/login'); + await page.getByLabel('Email or Username').fill('demo-user'); + await page.getByLabel('Password').fill('not-a-real-password'); + await page.getByRole('button', { name: 'Sign in' }).click(); + + const alert = page.getByRole('alert'); + await expect(alert).toContainText(/too many attempts/i); + await expect(page.getByLabel('Email or Username')).toHaveValue('demo-user'); + await expect(page.getByLabel('Password')).toHaveValue('not-a-real-password'); + }); + + test('unexpected server failure presents a safe support reference', async ({ page }) => { + await page.route('**/api/auth/token', async route => { + await route.fulfill({ + status: 500, + headers: { 'Content-Type': 'application/json', 'X-Request-ID': 'req-e2e-support' }, + body: JSON.stringify({ detail: 'Internal implementation detail must stay hidden.' }), + }); + }); + + await page.goto('/login'); + await page.getByLabel('Email or Username').fill('demo-user'); + await page.getByLabel('Password').fill('not-a-real-password'); + await page.getByRole('button', { name: 'Sign in' }).click(); + + const alert = page.getByRole('alert'); + await expect(alert).toContainText('Reference: req-e2e-support'); + await expect(alert).not.toContainText('implementation detail'); + }); +}); diff --git a/openspec/changes/establish-operational-readiness/.openspec.yaml b/openspec/changes/establish-operational-readiness/.openspec.yaml new file mode 100644 index 0000000..878dc31 --- /dev/null +++ b/openspec/changes/establish-operational-readiness/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-07 diff --git a/openspec/changes/establish-operational-readiness/design.md b/openspec/changes/establish-operational-readiness/design.md new file mode 100644 index 0000000..8f0d239 --- /dev/null +++ b/openspec/changes/establish-operational-readiness/design.md @@ -0,0 +1,68 @@ +## Context + +EchoEd is a FastAPI/SQLAlchemy backend and Angular SPA currently launched directly or with Docker Compose. Phase 10 supplies structured logs, request correlation, health/readiness, and process-local metrics, but operational behavior remains implicit: `start.sh` automatically upgrades the database, database and URL settings have development fallbacks, forwarded-header trust is not defined, uploads use local bind-mounted directories, and no repository tooling proves backup/restore or release drills. The current architecture has no worker queue, distributed metrics/rate-limit state, object-storage provider, or selected production hosting platform. + +This design treats operations as a contract at the application and repository boundary. It supplies fail-closed validation and deterministic, locally verifiable tools while leaving provider-specific deployment, scheduling, secret storage, backup destinations, and alert delivery to an operator-selected environment. + +## Goals / Non-Goals + +**Goals:** + +- Reject unsafe production configuration before application modules initialize or traffic is served, without disclosing values. +- Enforce allowed hosts and make forwarded metadata authoritative only for explicitly trusted network peers. +- Separate migration execution from normal process startup and codify preflight, readiness, smoke, rollback, and shutdown gates. +- Reuse Phase 10 health, metrics, and structured events for pragmatic SLOs and alerts. +- Identify all persistent state and provide safe backup/restore/integrity tooling plus repeatable non-production operational drills. +- Define defensible initial RPO/RTO targets, storage ownership, environment separation, secret rotation, escalation, and truthful limitations. + +**Non-Goals:** + +- Durable platform audit events, distributed rate limiting/state, identity redesign, new hosting/cloud/reverse-proxy/object-storage infrastructure, distributed tracing or telemetry expansion, application features, or claims of provider-independent disaster recovery. + +## Decisions + +1. **A standard-library operational settings module validates before application imports.** `app.operational_config` parses environment identity and the security-sensitive runtime contract without importing database, authentication, or route modules. `app.main` loads it first, and a CLI preflight uses the same validator. Production requires explicit database, secret, origin/URL, host, release, storage, migration, and observability decisions and rejects known development defaults. Errors report setting/category names only. Alternative: rely on Pydantic settings scattered across modules. Rejected because current settings are distributed and importing them can already create the engine. + +2. **Development/test keep explicit safe local defaults; production has none.** `APP_ENV` is read before optional dotenv loading. A production process never imports values from a checked-in/local `.env`; every required production value must be in its process environment. Alternative: load dotenv in all environments. Rejected because an image includes repository files and could silently inherit development secrets. + +3. **Host validation is ASGI-enforced and proxy trust is peer-based.** Starlette's trusted-host middleware rejects unexpected `Host` values. A small network-context resolver accepts `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` only when proxy trust is enabled and the direct socket peer matches an explicit IP/CIDR allowlist. Startup disables Uvicorn's implicit proxy-header processing so untrusted metadata cannot be applied before application checks. Alternative: trust all forwarded headers behind an assumed proxy. Rejected because no production topology is selected. + +4. **Migrations are an explicit release step.** Normal application startup validates configuration but never mutates schema. An explicit migration command performs validation and `alembic upgrade heads`; post-migration verification compares current heads with repository heads. Risky/irreversible changes require an operator-approved backup and compatibility plan. Alternative: retain automatic startup migration. Rejected because multiple instances can race and failed migrations blur release and process health. + +5. **Lifecycle hooks expose bounded drain diagnostics and release resources.** Uvicorn is configured with a finite graceful-shutdown timeout; on ASGI shutdown EchoEd records lifecycle events and disposes the SQLAlchemy engine. Actual HTTP acceptance/draining remains the server's responsibility. With no worker framework, no invented job-drain protocol is added. + +6. **Release validation is deployment-neutral automation.** A Python operational CLI validates config, database connectivity/migration state, health endpoints, and safe drill workflows. Container Compose remains a development reference but gains health ordering, explicit migration invocation, immutable release guidance, and shutdown bounds. Alternative: add Kubernetes/cloud manifests. Rejected as an unsupported infrastructure decision. + +7. **Backup tooling proves format, integrity, restore, and storage recovery using safe data.** Repository tooling supports deterministic SQLite plus filesystem-asset drill bundles with a versioned manifest and SHA-256 checksums. Production PostgreSQL uses documented native logical/managed backups and the same verification principles; the repository does not pretend a SQLite copier backs up PostgreSQL. Configuration is reproducible from versioned non-secret templates; secrets are backed up only by the operator's secret manager. Static Angular assets are rebuilt, while user-upload directories and the database are persistent sources of truth. + +8. **Initial objectives are conditional operational targets.** Availability, successful-request rate, server-error rate, latency, and readiness are defined over a rolling 30-day window with alert conditions using Phase 10 signals. The initial recovery targets are RPO 24 hours and RTO 4 hours, conditional on daily encrypted off-host database/upload backups and a measured restore drill. Process-local metrics cannot calculate fleet-wide compliance; external aggregation/alert delivery remains required. + +9. **Drills are executable, isolated, and evidence-producing.** A test/drill module uses temporary directories, a disposable SQLite database, local ASGI/Uvicorn lifecycle control, and synthetic configuration. It never targets production and reports pass/fail facts without secret values. Documentation records prerequisites, commands, expected/observed behavior, and limitations. + +10. **Rollback is three distinct operations.** Application rollback redeploys a previous immutable artifact; configuration rollback restores a reviewed previous non-secret configuration and matching secrets; database rollback is never assumed and requires a verified downgrade or backup restore. Compatibility gates decide when artifact-only rollback is safe. + +## Risks / Trade-offs + +- **[Strict production validation can reject previously tolerated deployments]** -> Document every required setting, provide a validation-only command, and retain development/test defaults. +- **[Application parsing cannot secure headers already rewritten by the server]** -> Launch Uvicorn with implicit proxy processing disabled and document explicit network topology requirements. +- **[Local filesystem uploads can disappear with an ephemeral container]** -> Require an explicit persistence acknowledgement and absolute storage paths in production; document external object storage as deferred infrastructure. +- **[Repository backup tooling could be mistaken for a PostgreSQL backup solution]** -> Refuse unsupported database schemes and clearly separate the safe local drill from production-native backup commands. +- **[Process-local metrics cannot establish fleet SLOs or deliver alerts]** -> Label objectives as targets, document aggregation limitations, and never claim external notification wiring. +- **[A schema change can make code rollback unsafe]** -> Require expand/contract compatibility or an approved restore/downgrade plan before migration. +- **[Shutdown tests cannot reproduce every platform signal behavior]** -> Verify the ASGI/Uvicorn lifecycle and resource cleanup deterministically, then require platform-specific termination drills after a host is selected. + +## Migration Plan + +1. Deploy the validator and run it against a staged production-equivalent environment without starting the API. +2. Create encrypted, off-host backups and verify integrity before changing startup/migration behavior. +3. Run explicit Alembic migration and head verification as a single controlled release step. +4. Deploy an immutable application artifact with automatic migrations disabled and implicit proxy headers disabled. +5. Verify liveness, then readiness, smoke checks, metrics/log signals, and a bounded monitoring period. +6. On failure, decide separately whether to roll back application, configuration, or restore/downgrade the database based on schema compatibility. +7. Retain the previous known-good artifact and reviewed configuration until the monitoring gate closes. + +## Open Questions + +- The hosting platform, reverse-proxy addresses, external metrics/log aggregation, alert-delivery provider, backup scheduler/destination, encryption key custody, and object-storage destination remain operator/infrastructure decisions. +- Production RPO/RTO must be re-measured against the selected managed database, data volume, and storage system before a production commitment. +- Durable, searchable, tamper-resistant security history remains owned by `implement-platform-audit-events`. diff --git a/openspec/changes/establish-operational-readiness/proposal.md b/openspec/changes/establish-operational-readiness/proposal.md new file mode 100644 index 0000000..72ac2c9 --- /dev/null +++ b/openspec/changes/establish-operational-readiness/proposal.md @@ -0,0 +1,30 @@ +## Why + +Phase 10 made EchoEd observable, but the repository still lacks a fail-closed production configuration contract and verified procedures for deployment, migration, rollback, shutdown, backup, and recovery. This change turns the existing observability foundation into an evidence-backed operational contract without inventing hosting infrastructure or broadening product scope. + +## What Changes + +- Add centralized, environment-aware production configuration validation that rejects missing, unsafe, malformed, contradictory, or development-only settings before traffic is served. +- Enforce explicit allowed-host and trusted-proxy boundaries; untrusted forwarded metadata never becomes authoritative. +- Separate database migration execution from application startup and add deterministic pre-deploy, migration, post-deploy, health, rollback, and shutdown procedures. +- Define initial service objectives, alert conditions, operational ownership, escalation, and health-check usage based on Phase 10 signals. +- Add safe, executable backup, integrity-verification, restore, storage-recovery, configuration-rotation, and operational-drill tooling for repository-supported local/test data. +- Document database/application/configuration rollback boundaries, persistent-storage ownership, RPO/RTO assumptions, and infrastructure-dependent limitations. +- Preserve Phase 8 security and Phase 10 observability behavior, existing application workflows, and the separate future durable audit-event change. + +## Capabilities + +### New Capabilities + +- `platform-operational-readiness`: Fail-closed production configuration, trusted network boundaries, deterministic release lifecycle, migration/rollback policy, health and shutdown integration, service objectives and alerts, backup/restore, storage ownership, secret rotation, and verified operational drills. + +### Modified Capabilities + +- None. + +## Impact + +- Backend startup/configuration, FastAPI middleware and lifecycle, SQLAlchemy resource shutdown, and Uvicorn/container launch behavior. +- Deployment/container and CI validation, Alembic operational procedures, and repository-provided operator scripts. +- Operations, architecture, security, roadmap, and platform-maturity documentation plus targeted backend tests and drill evidence. +- No database schema migration, commercial dependency, hosting-provider change, distributed state, authentication redesign, or application feature work is introduced. diff --git a/openspec/changes/establish-operational-readiness/specs/platform-operational-readiness/spec.md b/openspec/changes/establish-operational-readiness/specs/platform-operational-readiness/spec.md new file mode 100644 index 0000000..7ba6b10 --- /dev/null +++ b/openspec/changes/establish-operational-readiness/specs/platform-operational-readiness/spec.md @@ -0,0 +1,113 @@ +## ADDED Requirements + +### Requirement: Fail-closed production configuration +The system MUST validate security-sensitive and operational configuration before serving production traffic, MUST reject missing, malformed, contradictory, unsafe, or development-only values, and MUST report actionable setting categories without exposing secret values. Development and test environments SHALL retain usable local configuration. + +#### Scenario: Valid production configuration +- **WHEN** an operator supplies every required production setting with mutually consistent safe values +- **THEN** validation succeeds before application initialization and exposes no setting values + +#### Scenario: Unsafe production configuration +- **WHEN** a production setting is absent, malformed, uses a known development default, or contradicts another setting +- **THEN** startup fails before traffic is served and identifies only the affected setting or category + +### Requirement: Trusted host and proxy boundary +The backend MUST enforce an explicit host allowlist and MUST treat forwarding metadata as authoritative only when proxy trust is enabled and the direct peer is in an explicit IP or CIDR allowlist. Untrusted forwarding metadata MUST NOT alter the authoritative client address, protocol, or host. + +#### Scenario: Allowed and rejected hosts +- **WHEN** requests use an allowed host and then a host outside the configured allowlist +- **THEN** the allowed request is processed and the unexpected host is rejected + +#### Scenario: Spoofed forwarding metadata +- **WHEN** an untrusted direct client supplies forwarding headers +- **THEN** the backend uses direct connection metadata rather than the supplied forwarding values + +#### Scenario: Trusted forwarding metadata +- **WHEN** a configured trusted proxy supplies valid bounded forwarding headers +- **THEN** the backend resolves the forwarded client, protocol, and host according to the documented topology + +### Requirement: Deterministic deployment and migration lifecycle +The repository SHALL provide deterministic pre-deployment validation, explicit database migration, application startup, liveness/readiness, smoke, monitoring, and rollback decision procedures. Normal production application startup MUST NOT automatically execute migrations. + +#### Scenario: Successful release lifecycle +- **WHEN** an operator validates configuration, executes required migrations, starts the immutable release, and runs post-deployment checks +- **THEN** each gate produces a clear pass/fail result before the release proceeds + +#### Scenario: Migration failure +- **WHEN** a migration fails or the database schema does not reach the repository heads +- **THEN** application rollout is stopped and no automatic downgrade is claimed + +### Requirement: Explicit rollback boundaries +Operational documentation MUST distinguish application, configuration, and database rollback and MUST identify schema compatibility conditions under which application-only rollback is unsafe. + +#### Scenario: Compatible application rollback +- **WHEN** a failed release has no incompatible schema or configuration transition +- **THEN** the operator can redeploy the previous immutable artifact and verify readiness and smoke checks + +#### Scenario: Incompatible schema transition +- **WHEN** the previous application cannot operate safely against the migrated schema +- **THEN** the procedure requires a verified downgrade or backup restore rather than application-only rollback + +### Requirement: Operational health and graceful shutdown +The lifecycle MUST use process-only liveness, dependency-aware readiness, and bounded graceful shutdown. Database unavailability MUST fail readiness without failing liveness, health output MUST remain non-disclosing, and shutdown MUST release application/database resources and emit bounded operational events. + +#### Scenario: Database unavailable +- **WHEN** the database dependency is unavailable +- **THEN** liveness remains healthy while readiness returns an unavailable status without connection details + +#### Scenario: Graceful termination +- **WHEN** the server receives a controlled termination request +- **THEN** it stops accepting work according to server semantics, allows bounded in-flight completion, executes shutdown hooks, and releases database resources + +### Requirement: Service objectives and alert ownership +The system documentation SHALL define measurable initial availability, successful-request, error-rate, latency, and readiness indicators with targets, windows, known limitations, alert conditions, severity, response, owner role, escalation path, and runbook. It MUST distinguish exposed signals from external aggregation and notification infrastructure that is not configured. + +#### Scenario: Operator evaluates a service objective +- **WHEN** an operator reviews the documented Phase 10 health, metric, and log signals +- **THEN** the indicator formula, target, window, data limitations, owner, and related response are unambiguous + +### Requirement: Persistent-state backup and verified restore +The operational contract MUST identify all persistent state and define backup scope, cadence, retention, encryption, separation, integrity verification, and restore testing. A backup SHALL NOT be considered valid until an isolated restore proves database and supported uploaded-asset usability. + +#### Scenario: Safe recovery drill +- **WHEN** an operator backs up disposable database and upload data, verifies the manifest, restores to isolated targets, and runs usability checks +- **THEN** the restored records and asset bytes match the originals without exposing sensitive data + +#### Scenario: Corrupted backup +- **WHEN** a backup file no longer matches its integrity manifest +- **THEN** restore fails closed before replacing the target state + +### Requirement: Storage ownership and recovery targets +Documentation MUST identify the source of truth, persistence boundary, backup owner, restore owner, deployment behavior, and loss consequences for database, uploaded assets, generated static assets, configuration, and secrets. It SHALL define defensible initial RPO and RTO targets and their prerequisites and limitations. + +#### Scenario: Ephemeral storage configuration +- **WHEN** production upload paths do not have an explicit persistent-storage decision +- **THEN** production configuration validation fails rather than silently accepting ephemeral ownership + +### Requirement: Secret rotation and environment separation +Production MUST NOT silently load development secrets or insecure defaults. The operational contract SHALL define preparation, sequencing, verification, invalidation where supported, and emergency rollback for rotating application secrets and credentials while keeping secret values out of repository artifacts and evidence. + +#### Scenario: Rotation simulation +- **WHEN** an operator validates old and replacement production configurations in an isolated simulation +- **THEN** both configurations pass only with independently supplied safe secrets and no secret value appears in output + +### Requirement: Evidence-driven operational drills +The repository SHALL provide repeatable safe drills for invalid production configuration, unavailable database/readiness, startup, shutdown, health, failed post-deploy verification, backup, restore, rollback, configuration rotation, and storage recovery. Each drill MUST define prerequisites, procedure, expected and observed behavior, and pass/fail criteria and MUST refuse production or uncontrolled data where automation could be destructive. + +#### Scenario: Complete local drill execution +- **WHEN** an engineer runs the operational drill suite against isolated test resources +- **THEN** every supported drill records a deterministic pass/fail result and temporary resources are removed + +### Requirement: Security and observability preservation +Operational readiness MUST preserve Phase 8 authorization and privacy controls and Phase 10 logging, correlation, metrics, and health behavior. Logs, metrics, health output, errors, documentation, and drill evidence MUST NOT expose credentials, tokens, authorization data, private content, uploaded bytes, SQL values, or private user information. + +#### Scenario: Operational failure evidence +- **WHEN** validation, readiness, backup integrity, or deployment verification fails +- **THEN** diagnostics identify the operational category and safe reference context without exposing protected values + +### Requirement: Explicit deferred capability boundary +The change MUST NOT implement durable platform audit events, distributed rate limiting/state, identity redesign, new hosting infrastructure, distributed observability expansion, or application feature work. + +#### Scenario: Future audit-event need +- **WHEN** an operational workflow needs durable tamper-resistant administrative history +- **THEN** the need is recorded for `implement-platform-audit-events` rather than implemented in this capability diff --git a/openspec/changes/establish-operational-readiness/tasks.md b/openspec/changes/establish-operational-readiness/tasks.md new file mode 100644 index 0000000..5c3ecf9 --- /dev/null +++ b/openspec/changes/establish-operational-readiness/tasks.md @@ -0,0 +1,44 @@ +## 1. Baseline and operational contract + +- [x] 1.1 Record branch, commit, dirty-tree boundaries, OpenSpec states, Phase 10 dependency, actual test baselines, deployment/configuration/database/storage/CI/startup findings, and preserve unrelated work +- [x] 1.2 Create strictly valid proposal, design, capability specification, and concrete task artifacts with explicit non-goals and durable-audit boundary +- [x] 1.3 Document the canonical production configuration contract and environment-separation rules without secret values + +## 2. Configuration and network trust + +- [x] 2.1 Implement centralized environment-aware operational settings and fail-closed production validation before application initialization +- [x] 2.2 Add a validation-only operator command and tests for valid, missing, unsafe, malformed, contradictory, development, test, and production configurations +- [x] 2.3 Enforce allowed hosts and explicit peer IP/CIDR proxy trust while ignoring spoofed forwarding metadata +- [x] 2.4 Add trusted-host/proxy tests for allowed, rejected, trusted, malformed, and untrusted requests + +## 3. Release, migration, health, and shutdown lifecycle + +- [x] 3.1 Remove automatic production migrations from normal startup and provide explicit migration/head-verification and preflight commands +- [x] 3.2 Implement deterministic deployment and post-deployment verification automation using liveness, readiness, and safe smoke gates +- [x] 3.3 Add bounded graceful-shutdown lifecycle events, database resource cleanup, and server shutdown configuration +- [x] 3.4 Add tests/drills for migration failure/gating, startup, health dependency failure, failed post-deploy checks, lifecycle shutdown, and resource cleanup +- [x] 3.5 Document deployment, migration compatibility/irreversibility, application/configuration/database rollback, health integration, and shutdown procedures + +## 4. Backup, restore, storage, and rotation + +- [x] 4.1 Implement safe disposable SQLite/upload backup bundles with versioned manifests, checksums, integrity verification, isolated restore, and production-data refusal boundaries +- [x] 4.2 Add tests for backup creation, manifest integrity, corrupted-backup rejection, database restore usability, asset restore, and target safety +- [x] 4.3 Document production database/upload backup scope, scheduling/retention/encryption/separation, restore ownership, static/upload sources of truth, and loss behavior +- [x] 4.4 Establish defensible conditional RPO/RTO targets and document production-native PostgreSQL restore validation requirements +- [x] 4.5 Document and safely simulate environment-specific secret/configuration rotation, verification, invalidation, emergency rollback, and storage recovery + +## 5. SLOs, alerts, drills, and operational guidance + +- [x] 5.1 Define pragmatic availability, success/error, latency, readiness, authentication, and rate-limit indicators/targets/windows with process-local limitations +- [x] 5.2 Define alert signals, conditions, severities, owner roles, escalation paths, responses, and related runbooks without claiming external delivery +- [x] 5.3 Implement a repeatable isolated operational drill runner covering invalid production configuration, database/readiness failure, startup/shutdown, health, deployment failure, backup/restore, rollback, rotation, and storage recovery +- [x] 5.4 Execute drills, capture prerequisites/expected/observed/pass-fail evidence, measure backup/restore timings, and remove disposable artifacts +- [x] 5.5 Update operations, incident, architecture, security, README, roadmap, and platform-maturity documents with cross-references and explicit deferred infrastructure/audit work + +## 6. Verification and completion evidence + +- [x] 6.1 Run targeted operational tests plus the complete backend suite and repository-supported backend compile/static/lint/format checks without baseline regression +- [x] 6.2 Run the complete Angular suite, production build, configured frontend lint/format checks, and production dependency audit without baseline regression +- [x] 6.3 Run the complete Playwright suite and verify Phase 8/10 health, privacy, authorization, rate-limit, upload, metrics, and logging boundaries remain intact +- [x] 6.4 Run strict OpenSpec validation, `git diff --check`, secret/temp-artifact checks, and remove temporary servers, databases, backups, logs, and generated drill data +- [x] 6.5 Publish exact Phase 11 verification evidence, known limitations, deferred work, recommended next change, and mark tasks complete only where implementation/evidence exists diff --git a/openspec/changes/establish-platform-observability/.openspec.yaml b/openspec/changes/establish-platform-observability/.openspec.yaml new file mode 100644 index 0000000..878dc31 --- /dev/null +++ b/openspec/changes/establish-platform-observability/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-07 diff --git a/openspec/changes/establish-platform-observability/design.md b/openspec/changes/establish-platform-observability/design.md new file mode 100644 index 0000000..c55a238 --- /dev/null +++ b/openspec/changes/establish-platform-observability/design.md @@ -0,0 +1,69 @@ +## Context + +Phase 7 introduced basic request IDs, request timing logs, and liveness/readiness. Phase 8 added privacy-safe security events, fixed-window rate limiting, hardened uploads, and the completed Course Studio added critical draft/review/publish workflows. The backend is a single FastAPI/Uvicorn process using SQLAlchemy; Angular is a bearer-token SPA; no job runner, metrics scraper, tracing backend, or commercial monitoring vendor is selected. Current logs are human strings with embedded key/value fields, raw paths are logged, exceptions are not centrally categorized into safe client responses, metrics are absent, and frontend components sometimes log raw error objects. + +The change must remain deployable in the current architecture, preserve all security boundaries, avoid new high-cardinality or personal-data surfaces, and keep durable audit history separate. Local measurements and process-local metrics cannot establish production-scale guarantees. + +## Goals / Non-Goals + +**Goals:** + +- Emit consistent JSON or developer-readable backend events with stable names, safe context, centralized redaction, and environment validation. +- Give every request a canonical safe request ID (accepting only a bounded safe upstream value, otherwise generating one), preserve a separate sanitized correlation hint, normalize route templates, and return safe diagnostic references. +- Count and time HTTP, authentication, authorization, throttling, upload, database, and Course Studio outcomes with bounded labels and a vendor-neutral Prometheus text export. +- Keep liveness process-only and readiness dependency-aware, bounded, machine-readable, and non-disclosing. +- Make Angular failures safely correlatable through response request IDs while retaining accessible messages and user work. +- Supply operational and incident guidance plus measured local overhead. + +**Non-Goals:** + +- Durable/append-only audit storage, SIEM, commercial monitoring, a distributed tracing backend, a queue/job framework, cloud/network architecture, business or learner analytics, distributed rate limiting, authentication redesign, or unrelated authoring/product expansion. + +## Decisions + +1. **One observability module owns configuration, redaction, structured events, and metrics.** `app.observability` will use the standard library to avoid a monitoring-vendor dependency. It exposes small `emit_event`, metric, context, and export primitives. Existing `app.log.logger` remains the application logger but receives the configured formatter/filter. Alternative: add Prometheus/OpenTelemetry packages. Rejected for this phase because a bounded single-process foundation does not need an external SDK, and the deployment/export backend is not selected. + +2. **The server establishes a bounded safe request ID.** A caller/upstream `X-Request-ID` is accepted only when it matches the strict safe alphabet and length contract; absent or invalid values are replaced with a generated UUID. A separately sanitized, length-bounded `X-Correlation-ID` hint may be accepted and returned. Context variables make both values available to logs and internal code, and are reset after the request. Neither identifier is trusted for identity or authorization, and request IDs remain distinct from trace IDs; no W3C trace context is claimed. + +3. **Middleware owns HTTP lifecycle instrumentation and safe unexpected responses.** It increments active requests, records method, normalized route template, status family, duration, actor classification, and safe organization presence. Raw query strings, bodies, user agents, and resource IDs never become metric labels. Known HTTP/validation responses retain framework semantics; unexpected exceptions become a generic JSON 500 containing only a message and request reference, while server logs retain the stack trace in a single `request.unhandled_exception` event. + +4. **Metrics are in-memory and low-cardinality.** A locked counter/histogram registry emits Prometheus text without exemplars or IDs. Stable allowlisted labels include route template, method, status family/outcome, role category, limiter group, upload category, Course Studio operation, and database operation/result. The process-local reset/restart/multi-worker limitation is explicit. + +5. **Metrics export is disabled by default and token-protected when enabled.** `METRICS_ENABLED` controls collection; `METRICS_ENDPOINT_ENABLED` controls `/internal/metrics`. Enabling the endpoint requires a non-empty `METRICS_ACCESS_TOKEN`; clients provide it in `X-Metrics-Token`. A disabled endpoint is concealed as 404 and unauthorized access returns 403 without endpoint data. Alternative: platform-admin bearer access. Rejected because scraper access is operational, not a user-management permission, and would couple infrastructure scraping to an interactive account/JWT. + +6. **Health checks stay public but minimal.** Liveness never probes dependencies. Readiness performs `SELECT 1` using the existing engine, measures a bounded operation, and returns only overall status and a generic dependency state; failure is 503 and emits/records a database failure without host/database/exception text. Storage paths are not treated as mandatory readiness dependencies because current public static mounts create directories at startup and no independent storage service exists. + +7. **Database observability wraps real boundaries, not SQL statements.** `get_db` logs/metrics session rollback/operation failures without SQL text or bound values. Readiness instruments connection failure. SQL echo remains off. Slow-query tracing and pool hooks are deferred until real production pool/deployment parameters are selected. + +8. **Course Studio emits high-level operational events at API boundaries.** Import validation, draft creation/save/conflict, preview failure, review transitions, publish attempt/success/failure, and restore/duplicate/template operations use safe IDs in logs but only bounded operation/result labels in metrics. Course graphs, lesson content, assessment responses, feedback text, and imported documents are excluded. Frontend autosave/publish failures retain drafts and capture only safe status/reference/operation metadata locally. + +9. **Frontend diagnostics remain local and privacy-safe.** A shared service extracts `X-Request-ID`, stores the most recent safe reference, and produces a copyable suffix only for unexpected server failures. A global Angular `ErrorHandler` records stable categories and sanitized metadata to the developer console without raw error objects, tokens, request bodies, course content, or stack traces for normal users. Lazy-chunk recovery remains the user-facing behavior. + +10. **Security logs integrate; audit remains separate.** Phase 8 events call the same structured emitter and metrics primitives. Operational records may be rotated, sampled, or lost and do not promise retention/tamper resistance. `implement-platform-audit-events` owns persistence, append-only semantics, before/after state, access control, retention, export, search, and administrative review. + +## Risks / Trade-offs + +- **[Process-local metrics reset and diverge across workers]** → Document single-process semantics and require scraper-side aggregation/shared instrumentation in a later deployment change. +- **[Structured context could leak identifiers or secrets]** → Central redaction, allowlisted fields/labels, no bodies/queries, and regression tests for nested structures and header/token patterns. +- **[Authorization denial logs become noisy]** → Emit stable warning-level outcomes without target contents and aggregate metrics by scope/reason category, not ID. +- **[Metrics token is another secret]** → Endpoint disabled by default, fail configuration when exposure lacks a token, compare using constant-time logic, never log the token. +- **[Middleware cannot know route/actor until request processing]** → Resolve the route template after `call_next`; auth dependencies attach safe actor role/org context to request state when available. +- **[Catching unhandled exceptions could alter tests/behavior]** → Preserve known FastAPI errors, return the standard safe 500 status, attach request headers, and add focused regression tests before full suites. +- **[Instrumentation adds latency]** → Use lock-bounded in-memory updates, avoid payload serialization, and measure representative health/auth/course operations locally with metrics on/off. +- **[Low disk space destabilizes verification]** → Keep artifacts textual/small, avoid new packages, clean only generated outputs, and report any incomplete full-suite result rather than fabricating success. + +## Migration Plan + +1. Add the observability primitives and configuration with safe development defaults; no schema migration. +2. Replace request middleware/log formatting while preserving response security headers and existing request-ID compatibility. +3. Integrate security, database, upload, auth/authz, rate-limit, and Course Studio events incrementally with tests. +4. Add the disabled-by-default protected metrics endpoint and deployment-neutral documentation. +5. Add Angular reference handling/global diagnostics and focused tests. +6. Run full suites/build/audits, measure local overhead, then deploy with metrics endpoint disabled until an operator supplies a token and private access path. +7. Rollback is code/config rollback; no persistent observability schema or data migration exists. + +## Open Questions + +- Which production scraper/log collector and retention targets will be approved remains deployment work. +- Which routes warrant slow-operation alerts depends on production latency baselines; this phase provides configurable thresholds and local measurements only. +- Durable security action completeness, retention, tamper resistance, and operator search remain intentionally unresolved for `implement-platform-audit-events`. diff --git a/openspec/changes/establish-platform-observability/proposal.md b/openspec/changes/establish-platform-observability/proposal.md new file mode 100644 index 0000000..ad51f69 --- /dev/null +++ b/openspec/changes/establish-platform-observability/proposal.md @@ -0,0 +1,31 @@ +## Why + +EchoEd now has mature security boundaries and a major Course Studio workflow, but operators still lack consistent structured diagnostics, vendor-neutral metrics, safe frontend/backend correlation, and actionable runbooks. This focused change establishes privacy-conscious platform observability without substituting for authorization, inventing production infrastructure, or building the separate durable audit-event product. + +## What Changes + +- Standardize environment-configurable structured backend logging with stable event names, centralized redaction, request/correlation context, and safe exception categories. +- Instrument HTTP traffic, authentication, authorization, rate limiting, uploads, database failures, and supported Course Studio lifecycle operations without logging bodies, secrets, learner content, or high-cardinality metric labels. +- Preserve server-generated request IDs, safely accept bounded correlation hints, return diagnostic identifiers, and make unexpected frontend errors referenceable without exposing exception details. +- Clarify bounded liveness, readiness, and dependency checks and introduce a protected vendor-neutral metrics export. +- Add safe Angular diagnostic handling for HTTP failures, lazy chunks, Course Studio autosave/publish errors, and backend reference IDs while preserving accessible user messaging. +- Document current architecture, logging/redaction/metrics policies, database and Course Studio signals, background-work reality, measured local overhead, operator workflows, incident guidance, and the explicit boundary for `implement-platform-audit-events`. +- Preserve existing supported learner, teacher, Studio, organization, platform-administration, public, authentication, onboarding, and Phase 8 security behavior. + +## Capabilities + +### New Capabilities + +- `platform-observability`: Structured logging, request correlation, HTTP/error/database instrumentation, health/readiness, vendor-neutral metrics, frontend diagnostic correlation, privacy controls, and operational guidance. + +### Modified Capabilities + +- `auth-org-session-authority`: Authentication, authorization, and rate-limit outcomes gain privacy-safe operational signals while existing authority and organization-scope behavior remains unchanged. + +## Impact + +- Backend: FastAPI middleware/exception handling, logging, metrics, health/readiness, database session diagnostics, Phase 8 security events, uploads, authentication, authorization dependencies, and Course Studio routes/services. +- Frontend: Angular HTTP interception, global error handling, lazy-load diagnostics, safe reference-ID presentation, and Course Studio failure diagnostics. +- Operations/docs/tests: new observability and incident documentation plus backend, Angular, and stable Playwright regressions. +- Dependencies: no commercial monitoring vendor; prefer standard-library/vendor-neutral implementation and add no dependency unless repository constraints prove it necessary. +- Non-goals: durable audit persistence, SIEM, distributed tracing infrastructure, new cloud architecture, business/learner analytics, distributed rate limiting, authentication replacement, and unrelated product capability work. diff --git a/openspec/changes/establish-platform-observability/specs/auth-org-session-authority/spec.md b/openspec/changes/establish-platform-observability/specs/auth-org-session-authority/spec.md new file mode 100644 index 0000000..592f220 --- /dev/null +++ b/openspec/changes/establish-platform-observability/specs/auth-org-session-authority/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: Authentication and authorization outcomes produce bounded operational signals +The platform SHALL record privacy-safe operational events and low-cardinality metrics for authentication success/failure, authorization denial, cross-organization denial, and rate-limit triggers without changing backend authority decisions or exposing account identifiers, tokens, organization names, or protected target data as metric labels. + +#### Scenario: Login succeeds +- **WHEN** valid credentials produce a session token +- **THEN** the backend records an authentication success outcome correlated to the request without logging the credentials or token + +#### Scenario: Organization-scoped authorization is denied +- **WHEN** an authenticated actor fails an organization role or membership policy +- **THEN** the backend records a bounded authorization denial category while preserving the existing 403 or concealment behavior + +#### Scenario: Authentication is throttled +- **WHEN** the configured authentication limiter returns 429 +- **THEN** the security event and operational rate-limit metric share request correlation without exposing the account identifier or limiter storage key diff --git a/openspec/changes/establish-platform-observability/specs/platform-observability/spec.md b/openspec/changes/establish-platform-observability/specs/platform-observability/spec.md new file mode 100644 index 0000000..b3ce39e --- /dev/null +++ b/openspec/changes/establish-platform-observability/specs/platform-observability/spec.md @@ -0,0 +1,99 @@ +## ADDED Requirements + +### Requirement: Backend diagnostics are structured and privacy-conscious +The backend SHALL emit environment-configurable events with stable names, severity, component, environment, request/correlation context, and allowlisted operational fields while centrally redacting secrets and excluding request bodies, protected content, and unnecessary personal data. + +#### Scenario: JSON logging is enabled +- **WHEN** the configured log format is JSON +- **THEN** each application event is serialized as a structured object with stable field names and no secret values + +#### Scenario: Nested sensitive data reaches the logger +- **WHEN** an event contains authorization, cookie, password, token, or secret fields in nested data +- **THEN** the sensitive values are replaced before formatting and never appear in emitted output + +### Requirement: Requests have safe identity and correlation context +The backend SHALL establish a canonical safe request ID for every request by accepting only a strictly bounded safe upstream value or generating a new identifier, return it in `X-Request-ID`, make it available to logs/error handlers, and treat a sanitized caller correlation hint separately from request and distributed trace identity. + +#### Scenario: Request has no correlation headers +- **WHEN** a request enters the API without an accepted correlation hint +- **THEN** the server replaces it with a generated request ID and returns it without trusting caller identity + +#### Scenario: Caller supplies invalid correlation text +- **WHEN** `X-Correlation-ID` exceeds the limit or contains disallowed characters +- **THEN** the server discards it and does not include it in logs or responses + +### Requirement: HTTP and exception instrumentation is safe and low-cardinality +The platform SHALL observe request count, active requests, normalized route, status family, and duration without recording raw queries, bodies, personal identifiers, or raw object IDs as metric labels, and SHALL categorize validation, authorization, dependency, and unexpected failures safely. + +#### Scenario: Parameterized route completes +- **WHEN** a request to a route containing a resource identifier completes +- **THEN** logs and metrics use the normalized route template rather than the supplied identifier + +#### Scenario: Unexpected exception occurs +- **WHEN** application code raises an unhandled exception +- **THEN** the server logs one categorized exception with request context and returns a generic 500 response containing only a safe reference ID + +### Requirement: Liveness and readiness have distinct non-disclosing behavior +The platform SHALL expose process-only liveness and bounded mandatory-dependency readiness using machine-readable responses that omit credentials, host internals, versions, database names, and exception details. + +#### Scenario: Database is unavailable +- **WHEN** the process is running but the database readiness probe fails +- **THEN** liveness remains successful, readiness returns 503, and a safe database failure signal is emitted + +### Requirement: Vendor-neutral operational metrics are protected +The platform SHALL collect documented low-cardinality operational metrics without a commercial vendor and SHALL conceal metrics export unless explicitly enabled and authenticated by deployment configuration. + +#### Scenario: Metrics endpoint is disabled +- **WHEN** a client requests the metrics route under default configuration +- **THEN** the route returns 404 and exposes no metric names or application state + +#### Scenario: Authorized operator reads metrics +- **WHEN** metrics collection/export is enabled and the caller supplies the configured access token +- **THEN** the backend returns a standard Prometheus text representation without personal-data labels + +### Requirement: Database failures are observable without SQL data leakage +Database connection, readiness, rollback, and operation failures SHALL emit stable events and bounded metrics without unrestricted SQL echo, bound values, credentials, or protected record data. + +#### Scenario: Transaction fails +- **WHEN** a database-backed operation raises a SQLAlchemy failure +- **THEN** the transaction is rolled back and a safe database operation failure is correlated to the request + +### Requirement: Course Studio critical operations are observable +The platform SHALL emit high-level log and metric outcomes for supported Course Studio import, draft create/save/conflict, preview, review, publish, restore, and template operations without logging course graphs, lesson content, assessment answers, feedback text, or imported documents. + +#### Scenario: Draft save conflicts +- **WHEN** an author submits a stale revision and the backend rejects it +- **THEN** the platform records a bounded draft-save conflict outcome correlated to the request while preserving existing conflict behavior + +#### Scenario: Publish fails safety validation +- **WHEN** a publish attempt is blocked by safe-publish validation +- **THEN** the platform records publish attempt/failure signals without serializing validation content or course material + +### Requirement: Frontend errors retain safe backend references +Angular SHALL extract safe request references from backend responses, preserve accessible user-facing error behavior and entered work, and avoid sending or logging tokens, protected content, raw error bodies, or stack traces to any external vendor. + +#### Scenario: Unexpected API failure has request header +- **WHEN** Angular receives an unexpected server error with `X-Request-ID` +- **THEN** the user may receive a copyable safe reference while routine validation messages remain uncluttered + +#### Scenario: Course autosave fails +- **WHEN** an autosave request fails +- **THEN** unsaved authoring state remains available and diagnostics retain only operation, status, and safe request reference + +### Requirement: Actual background work has explicit observability boundaries +The platform SHALL document whether workers or scheduled tasks exist and SHALL require future real background execution to record start, success, failure, duration, retry, terminal failure, and safe correlation without inventing a queue in this change. + +#### Scenario: No executable worker exists +- **WHEN** repository background-work paths are audited +- **THEN** metadata-only job records are not represented as an operational queue or assigned fabricated queue-depth metrics + +### Requirement: Observability overhead and operations are documented +The change SHALL measure representative local instrumentation overhead, provide vendor-neutral health/log/metric/runbook guidance, and preserve a concrete boundary for durable audit events without claiming production-scale performance or readiness. + +#### Scenario: Operator investigates a frontend reference +- **WHEN** support receives a safe request reference +- **THEN** the runbook explains how to correlate it with backend logs without requesting credentials, tokens, learner content, or uploaded files + +#### Scenario: Durable audit history is requested +- **WHEN** an operator needs append-only retained actor/action/target history +- **THEN** documentation directs that requirement to `implement-platform-audit-events` rather than treating operational logs as authoritative history diff --git a/openspec/changes/establish-platform-observability/tasks.md b/openspec/changes/establish-platform-observability/tasks.md new file mode 100644 index 0000000..4bb10d5 --- /dev/null +++ b/openspec/changes/establish-platform-observability/tasks.md @@ -0,0 +1,48 @@ +## 1. Preconditions and architecture evidence + +- [x] 1.1 Confirm Phase 8 strict completion/archive state, other requested change states, branch/commit, dirty-tree boundaries, test baselines, and preserve unrelated work +- [x] 1.2 Complete the current observability audit across backend, Angular, dependencies, CI, deployment health, Course Studio, database, security events, and actual background-work architecture +- [x] 1.3 Define canonical logging, correlation, metrics, health, database, frontend, redaction, Course Studio, and durable-audit boundary documentation + +## 2. Structured logging and request lifecycle + +- [x] 2.1 Add environment-validated observability settings with JSON/developer formatting, log level, request logging, metrics/export, correlation header, and slow-request controls +- [x] 2.2 Implement centralized recursive sensitive-data redaction and structured event emission with stable names/context +- [x] 2.3 Replace request middleware with canonical bounded-or-generated request IDs, separate sanitized correlation hints, context propagation/reset, response headers, normalized routes, and security headers +- [x] 2.4 Add safe HTTP lifecycle/slow request metrics and structured completion events using bounded labels +- [x] 2.5 Add categorized validation, HTTP denial, database/dependency, and unexpected-exception handling with generic request-reference responses + +## 3. Health, metrics, and database + +- [x] 3.1 Implement a locked vendor-neutral counter/gauge/histogram registry with documented allowlisted label dimensions +- [x] 3.2 Add disabled-by-default, token-protected Prometheus text export with concealment and constant-time token comparison +- [x] 3.3 Preserve process-only liveness and implement bounded, non-disclosing database readiness success/failure signals +- [x] 3.4 Instrument database session failures/rollbacks and readiness connection failures without SQL text or bound values +- [x] 3.5 Add backend tests for settings validation, metrics presence/security/cardinality, liveness/readiness failure, database failure events, and safe health responses + +## 4. Domain and security observability + +- [x] 4.1 Integrate Phase 8 security events into structured logs and bounded security metrics without creating durable audit persistence +- [x] 4.2 Instrument authentication registration/login success/failure/throttle outcomes without account-existence or credential disclosure +- [x] 4.3 Instrument platform/organization authorization denials and cross-organization categories without protected target data +- [x] 4.4 Instrument upload attempt/success/rejection/duration and limiter outcomes without filenames, bytes, or personal labels +- [x] 4.5 Instrument Course Studio import, draft create/save/conflict, preview, review, publish, restore/duplicate/template outcomes at supported API boundaries +- [x] 4.6 Add backend regression tests for security/auth/authz/rate-limit/upload/Course Studio events and secret-free structured output + +## 5. Frontend diagnostic alignment + +- [x] 5.1 Add a privacy-safe Angular diagnostic service for request-reference extraction, unexpected-error references, operation/status categories, and sanitized console behavior +- [x] 5.2 Add an Angular HTTP diagnostic interceptor and global error handler without duplicating user-facing security handling or logging raw errors/content +- [x] 5.3 Integrate safe references into unexpected API, lazy-chunk, upload, authentication-expiry, and permission/rate-limit behavior where appropriate +- [x] 5.4 Add Course Studio autosave/publish failure diagnostics that preserve work and exclude graphs, lesson content, imports, tokens, and raw bodies +- [x] 5.5 Add Angular tests for reference extraction/display, global/lazy diagnostics, autosave/publish failures, and sensitive-data exclusion +- [x] 5.6 Add only stable user-visible Playwright coverage for safe server references and preserved role/error workflows + +## 6. Operations, performance, and verification + +- [x] 6.1 Create the observability runbook and incident guide covering required detection, correlation, containment, escalation, and safe evidence handling +- [x] 6.2 Update architecture, security, README, roadmap, Phase 8 security-event, platform observability baseline, and future audit-event dependency documentation +- [x] 6.3 Measure local instrumentation overhead for representative health/auth/Course Studio operations and document method, results, and non-production limitations +- [x] 6.4 Run complete backend tests and repository-supported backend syntax/dependency/format/lint checks without baseline regression +- [x] 6.5 Run complete Angular tests, production build, repository-supported frontend format/lint checks, and production dependency audit without baseline regression +- [x] 6.6 Run complete Playwright tests, strict OpenSpec validation, `git diff --check`, secret/temp-artifact checks, remove disposable services/data/logs, and publish Phase 10 verification evidence diff --git a/openspec/changes/harden-platform-security/.openspec.yaml b/openspec/changes/harden-platform-security/.openspec.yaml new file mode 100644 index 0000000..84cfc12 --- /dev/null +++ b/openspec/changes/harden-platform-security/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-06 diff --git a/openspec/changes/harden-platform-security/design.md b/openspec/changes/harden-platform-security/design.md new file mode 100644 index 0000000..3cf7689 --- /dev/null +++ b/openspec/changes/harden-platform-security/design.md @@ -0,0 +1,60 @@ +## Context + +EchoEd has a FastAPI/SQLAlchemy backend, Angular bearer-token client, explicit organization memberships, public legacy forum reads/writes, direct filesystem image uploads, and a single-process Uvicorn deployment. Phase 7 added baseline headers, privacy-safe request correlation, active-membership enforcement, and bounded upload streaming, then identified forum authorization, privileged-user schemas/invariants, and rate limiting as the next critical change. Phase 8 must harden those boundaries without replacing authentication, inventing a new organization model, or disturbing unrelated course-authoring work already present in the tree. + +## Goals / Non-Goals + +**Goals:** + +- Make every forum write authenticated and owner-validated while preserving intended public reads. +- Express global and organization roles as canonical backend allowlists and deny unrecognized roles. +- Restrict platform user administration by actor scope, target hierarchy, allowed fields, self-action, and final-admin invariants. +- Rate-limit authentication, uploads, invitations, forum writes, and privileged mutations through documented endpoint groups. +- Validate actual uploaded image bytes and dimensions before atomic storage. +- Return explicit minimized administrative schemas and privacy-safe security events. +- Expand object/cross-organization tests and keep supported Angular workflows compatible. + +**Non-Goals:** + +- Replacing JWT/bcrypt or adding SSO, OAuth, MFA, session revocation, or a new identity provider. +- Building complete moderation, asset-management, notification, audit-log UI, or commercial security products. +- Creating a new organization hierarchy, broad schema migration, framework rewrite, or unrelated product capability. + +## Decisions + +1. **Small policy primitives, not a policy engine.** `app.security` will define canonical platform/organization role sets, hierarchy helpers, target-user rules, object concealment helpers, and privacy-safe event logging. Existing `require_roles` and `require_org_roles` remain compatible but validate against centralized sets. This avoids scattered negated checks without adding framework complexity. + +2. **Global administration distinguishes `super_admin` and `admin`.** `super_admin` is the highest platform role; `admin` remains an existing platform administrator for compatibility. Both may read minimized platform user records. Only `super_admin` may grant/revoke `admin` or `super_admin` and modify another `super_admin`; ordinary admins may manage learner/teacher/content roles. A user may not change or delete their own administrative account through these endpoints. The final active `super_admin` cannot be demoted or deleted. Because `User` has no active/disabled field, Phase 8 does not invent deactivation and treats persisted users as usable. The database write transaction performs the last-admin count and mutation together; PostgreSQL target/admin rows are locked where supported. Organization-final-admin protection is not introduced because personal organizations and the current product allow membership lifecycle without a formal invariant. + +3. **Registration is server-controlled.** Public registration creates `student` globally regardless of caller input and continues creating the personal organization/admin membership. This closes public self-escalation while retaining onboarding. The legacy role field can be accepted for request compatibility but cannot influence authority. + +4. **Forum writes use authenticated identity and ownership.** Create payloads omit ownership; the server assigns `current_user.id`. Authenticated users can create threads/posts against existing parents, authors can edit/delete their own content, and platform forum moderators (`admin`, `super_admin`) can edit/delete any item. Updates change content/title only. Public list/detail reads remain available. Reactions, reports, pin/lock/hide/restore, attachments, and other moderation endpoints do not exist and therefore remain explicitly disabled (404), not partially implemented. + +5. **A configurable fixed-window limiter is process-local.** The checked-in production command starts one Uvicorn process, so a locked in-memory store is immediately effective and introduces no dependency. Policies live in one configuration module and key by direct socket peer plus normalized account identifier for anonymous authentication, or authenticated user ID for uploads/mutations. Forwarded headers are ignored until trusted proxy configuration exists. Responses use 429 plus `Retry-After`; authentication failures remain generic. The limitation for multiple processes/hosts is documented as follow-up work for a distributed store. High-risk groups fail closed if limiter evaluation itself fails; public availability-sensitive reads are not limited. + +6. **Image validation uses standard-library signature parsing.** PNG, JPEG, GIF, and WebP signatures/dimensions are checked from bounded bytes; the claimed extension and MIME must also agree. SVG and active formats remain rejected. Storage names stay server-generated and atomically completed. Public serving remains for product compatibility and is documented as residual same-origin/storage risk; uploads have no replacement/delete API to authorize. + +7. **Response schemas are endpoint-specific.** Platform user list/detail return only id, name, username, email, role, and created timestamp required by Angular. Password hashes, update timestamps, ORM relationships, and future model fields cannot serialize. Student lookup omits email. Invitation list responses omit bearer invitation tokens; creation returns the token once because the current no-email distribution workflow requires it, and acceptance continues using a submitted token with generic failures. + +8. **Error and concealment semantics are deliberate.** Missing/invalid credentials are 401; known authenticated policy denial is 403; cross-organization/object lookups are 404 where concealing existence is appropriate; lifecycle conflicts such as final-admin/self-lockout are 409; malformed payloads remain 422; limiter enforcement is 429. Angular maps safe error categories and preserves accessible live announcements. + +9. **Existing logs carry interim security events.** Structured key/value events include request ID, actor ID, action, target type/ID when authorized, result, and reason code. Passwords, tokens, raw file content, and unnecessary profile data are excluded. A durable, queryable audit ledger remains deferred to `implement-platform-audit-events`. + +## Risks / Trade-offs + +- [Process-local rate limits do not coordinate across scaled workers] → document the one-process guarantee and require a shared store before scaling API replicas. +- [Direct peer IP may collapse many users behind a reverse proxy] → combine account identifier for auth and authenticated user ID elsewhere; do not trust spoofable forwarding headers. +- [Existing clients may send forum `user_id` or full user DTOs] → Pydantic forbids sensitive extras on hardened mutation schemas; update the Angular client and tests in the same change. +- [Public static uploads remain same-origin and globally readable] → reject active formats, send `nosniff`, generate opaque names, and record object storage/private delivery as deferred work. +- [Concurrent final-admin changes can race] → lock relevant PostgreSQL rows within the mutation transaction; SQLite tests verify invariant behavior but cannot model production row locking. +- [Broad legacy object authorization remains heterogeneous] → add focused multi-organization tests to highest-risk existing routes and document any lower-risk gaps rather than rewriting every domain. + +## Migration Plan + +No database migration is planned. Deploy configuration with documented rate-limit defaults, deploy backend and Angular together, monitor 401/403/409/429/security-event rates, and retain rollback through the previous application artifact. A rollback restores permissive forum/admin behavior and is therefore an emergency availability measure only. Clear the in-memory limiter by process restart if configuration is mis-sized. + +## Open Questions + +- A future deployment decision must select a non-commercial shared rate-limit store before multiple API processes or hosts are enabled. +- A later audit-event change must decide retention, access, tamper resistance, and operator UI requirements. +- A future community specification must define organization-scoped forums, moderation roles, reports, reactions, and attachments before those capabilities are enabled. diff --git a/openspec/changes/harden-platform-security/proposal.md b/openspec/changes/harden-platform-security/proposal.md new file mode 100644 index 0000000..9053aed --- /dev/null +++ b/openspec/changes/harden-platform-security/proposal.md @@ -0,0 +1,29 @@ +## Why + +Phase 7 verified that EchoEd's legacy forum mutations, privileged user-management APIs, upload boundary, and sensitive endpoints lack several fail-closed controls required for responsible platform operation. This focused phase closes those evidenced gaps while preserving supported Student, Teacher, Studio, organization, platform-admin, public, authentication, and onboarding behavior. + +## What Changes + +- Require backend-authenticated, owner-scoped forum mutations and make every supported or unsupported community mutation policy explicit; public reads remain compatible. +- Replace broad privileged-user DTO/ORM behavior with explicit actor/target role allowlists, minimized response schemas, organization boundaries, self-action rules, and final-platform-administrator protection. +- Centralize small backend authorization primitives for platform roles, organization roles, ownership, and consistent concealment/error semantics. +- Add configurable rate limiting for authentication, uploads, invitations, forum writes, and privileged user mutations with `429` and retry metadata. +- Validate upload signatures and safe image properties in addition to existing size, name, MIME, and extension controls. +- Add privacy-safe structured security events using existing logging, while deferring a durable audit-log product. +- Align Angular errors and controls with `401`, `403`, `404`, `409`/`422`, and `429` backend outcomes, and expand backend, Angular, and stable browser authorization coverage. +- Document the threat model, endpoint decisions, role policy, residual risks, configuration assumptions, and verification evidence. +- Security-sensitive behavior may become more restrictive. Unauthenticated or unauthorized mutations fail closed; frontend guards and hidden controls are not security boundaries. + +## Capabilities + +### New Capabilities + +- `platform-security-hardening`: Backend-enforced authorization, administrator safety, abuse throttling, secure uploads, minimized administrative responses, organization isolation, security errors/events, frontend alignment, and regression verification. + +### Modified Capabilities + +- `auth-org-session-authority`: Registration role assignment and organization authority become explicit, server-controlled, and deny-by-default. + +## Impact + +Affected areas include FastAPI authentication/dependencies, forum, user, organization/invitation, and upload routes; Pydantic schemas; request/security logging; Angular admin and HTTP error behavior; backend/Angular/Playwright tests; deployment configuration; security and architecture documentation. Existing libraries are preferred, larger identity-provider/session redesigns are excluded, and no unrelated database migration or product capability is introduced. diff --git a/openspec/changes/harden-platform-security/specs/auth-org-session-authority/spec.md b/openspec/changes/harden-platform-security/specs/auth-org-session-authority/spec.md new file mode 100644 index 0000000..84dc3b3 --- /dev/null +++ b/openspec/changes/harden-platform-security/specs/auth-org-session-authority/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: Public registration cannot assign privileged authority +The backend SHALL assign a safe default global role and personal-organization membership through trusted server logic, regardless of client-supplied role or authority fields. + +#### Scenario: Registration requests an administrative role +- **WHEN** an anonymous registration request supplies `admin`, `super_admin`, or another non-default role +- **THEN** the created account receives only the safe default global role and the documented personal-organization membership + +### Requirement: Inactive organization memberships cannot establish authority +The platform SHALL require an active membership for organization switching and organization-scoped authorization, including any platform-administrator organization access path. + +#### Scenario: User selects an inactive membership +- **WHEN** an authenticated user attempts to switch to or authorize through an inactive organization membership +- **THEN** the backend conceals or denies the organization and preserves the prior session context diff --git a/openspec/changes/harden-platform-security/specs/platform-security-hardening/spec.md b/openspec/changes/harden-platform-security/specs/platform-security-hardening/spec.md new file mode 100644 index 0000000..636e57c --- /dev/null +++ b/openspec/changes/harden-platform-security/specs/platform-security-hardening/spec.md @@ -0,0 +1,118 @@ +## ADDED Requirements + +### Requirement: Forum mutations fail closed +The backend SHALL require authentication for every supported forum mutation, derive ownership from the authenticated user, enforce author-or-moderator changes, and leave unsupported mutation capabilities disabled. + +#### Scenario: Anonymous user attempts a forum mutation +- **WHEN** an anonymous actor creates, edits, or deletes a thread or post +- **THEN** the backend returns 401 and does not mutate forum state + +#### Scenario: Author edits owned forum content +- **WHEN** an authenticated author updates the mutable content of their own thread or post +- **THEN** the backend preserves ownership and applies the update + +#### Scenario: Non-owner attempts a forum mutation +- **WHEN** an authenticated non-moderator edits or deletes another author's content +- **THEN** the backend returns 403 without disclosing or changing protected fields + +### Requirement: Privileged user management uses explicit policy +The backend SHALL authorize platform user-management through explicit actor and target role allowlists, organization boundaries, allowed request fields, and deny-by-default behavior. + +#### Scenario: Lower administrator targets a higher role +- **WHEN** an admin attempts to modify a super administrator or grant a platform-administrator role +- **THEN** the backend returns 403 and preserves the target account + +#### Scenario: Mass assignment is attempted +- **WHEN** a privileged update contains a password, identifier, ownership, organization, or other non-allowlisted field +- **THEN** validation rejects the request and no sensitive field changes + +### Requirement: Administrator safety invariants are enforced +The backend SHALL prevent self-escalation, unsafe administrative self-lockout, and removal of the final usable highest-privileged platform administrator. + +#### Scenario: Final super administrator is removed +- **WHEN** a mutation would demote or delete the final usable super administrator +- **THEN** the backend rejects the conflict transactionally with an actionable safe error + +#### Scenario: Multiple super administrators exist +- **WHEN** an authorized super administrator demotes or deletes another super administrator and at least one usable super administrator remains +- **THEN** the backend permits the otherwise-valid mutation + +#### Scenario: Administrator targets self +- **WHEN** an administrator attempts to change their own authority or delete their own account through privileged management +- **THEN** the backend rejects the self-lockout or self-escalation action + +### Requirement: Sensitive routes are rate limited +The backend SHALL apply centrally configured fixed-window limits to authentication, uploads, invitations, forum writes, and privileged user mutations and SHALL return 429 with retry metadata when a limit is exceeded. + +#### Scenario: Authentication abuse exceeds a limit +- **WHEN** one safe authentication key exceeds the configured attempts in its window +- **THEN** the backend returns a generic 429 response with `Retry-After` without confirming account existence + +#### Scenario: Independent actors use a protected route +- **WHEN** distinct authenticated user keys use a limited mutation route within policy +- **THEN** each key receives an independent allowance + +#### Scenario: Window expires +- **WHEN** the configured fixed window has elapsed +- **THEN** the actor can make requests under a new allowance + +### Requirement: Existing uploads validate actual content +The backend SHALL authenticate upload actors, enforce explicit size and image-format allowlists, validate magic bytes and safe dimensions, generate storage names, and complete writes atomically. + +#### Scenario: Claimed image has a mismatched signature +- **WHEN** an upload has an allowed filename and MIME type but invalid or mismatched bytes +- **THEN** the backend rejects it without leaving a stored or partial file + +#### Scenario: Traversal filename is supplied +- **WHEN** an authorized actor uploads an otherwise-valid image with path segments in the client filename +- **THEN** the server stores it only under a generated name inside the configured destination + +### Requirement: Administrative responses are minimized +Privileged user and organization-administration endpoints SHALL use explicit response schemas that include only fields required by supported consumers and exclude secrets and backend-only relationships. + +#### Scenario: Administrator lists users +- **WHEN** an authorized administrator requests the user list +- **THEN** each response contains only the documented user summary fields and never includes password hashes or ORM relationships + +#### Scenario: Organization administrator lists invitations +- **WHEN** an organization administrator requests invitations +- **THEN** the response omits invitation bearer tokens and platform-only user information + +### Requirement: Object and organization authorization is enforced server-side +Every audited protected object route SHALL verify actor role, organization membership, ownership or parent scope as applicable, and SHALL conceal cross-organization resources consistently. + +#### Scenario: Organization A actor requests Organization B object +- **WHEN** an organization-scoped actor supplies a valid identifier belonging to another organization +- **THEN** the backend denies or conceals the object without returning its protected data + +#### Scenario: Child object belongs to another parent +- **WHEN** a client combines an authorized parent identifier with a child identifier from another scope +- **THEN** the backend rejects the mismatch and does not mutate either resource + +### Requirement: Security failures use consistent safe responses +The API SHALL use 401 for missing or invalid authentication, 403 for known permission denial, 404 for absence or intentional concealment, 409 for security-relevant state conflicts, 422 for validation, and 429 for throttling. + +#### Scenario: Protected operation is denied +- **WHEN** a security-sensitive request fails authentication, permission, state, validation, or rate policy +- **THEN** it returns the corresponding non-success status without stack traces, SQL details, tokens, or unnecessary existence disclosure + +### Requirement: High-impact actions emit privacy-safe events +The backend SHALL emit structured security events through the existing logging boundary for privileged role/account actions, final-admin failures, rate-limit triggers, authentication failures, upload rejection, forum moderation, and cross-organization denials where the current architecture supports them. + +#### Scenario: Privileged mutation succeeds or fails +- **WHEN** a high-impact administrative action is evaluated +- **THEN** logging records correlation, actor, action, target type, result, and a safe reason without passwords, tokens, file contents, or unnecessary personal data + +### Requirement: Frontend behavior aligns with hardened APIs +Angular SHALL avoid controls that cannot succeed for the known actor and SHALL present accessible safe states for 401, 403, concealed 404, administrator conflicts, upload rejection, and 429 outcomes while backend enforcement remains authoritative. + +#### Scenario: Rate-limited form submission +- **WHEN** the API returns 429 for a supported user action +- **THEN** Angular announces a safe retry message accessibly and preserves entered form content where safe + +### Requirement: Security verification preserves supported workflows +The change SHALL preserve or legitimately increase backend, Angular, and Playwright collection baselines and SHALL add direct-request regression coverage for forum, admin safety, rate limits, uploads, object authorization, and cross-organization isolation. + +#### Scenario: Phase 8 verification runs +- **WHEN** complete repository verification is executed +- **THEN** supported Student, Teacher, Studio, organization-admin, platform-admin, public, authentication, and onboarding workflows pass without an unexplained test reduction diff --git a/openspec/changes/harden-platform-security/tasks.md b/openspec/changes/harden-platform-security/tasks.md new file mode 100644 index 0000000..c2ec9fa --- /dev/null +++ b/openspec/changes/harden-platform-security/tasks.md @@ -0,0 +1,47 @@ +## 1. Preconditions and audit evidence + +- [x] 1.1 Verify Phase 7 completion, strict validity, archive status, branch/commit, and pre-existing worktree changes without modifying archived or unrelated work +- [x] 1.2 Record authentication, authorization, roles, organization scope, rate limiting, upload/forum/admin routes, existing tests, and verified test baselines in `docs/security/phase-8-security-baseline.md` +- [x] 1.3 Complete the trust-boundary threat model and endpoint/object/configuration audit documents with evidence and residual risks + +## 2. Canonical authorization and forum boundary + +- [x] 2.1 Add centralized explicit platform and organization role allowlists, hierarchy/ownership helpers, deny-by-default semantics, and privacy-safe security-event logging +- [x] 2.2 Secure thread create/update/delete with authentication, server-derived ownership, author-or-moderator checks, immutable ownership, and mutation rate limits +- [x] 2.3 Secure post create/update/delete with authenticated identity, parent existence, author-or-moderator checks, immutable ownership/parent scope, and mutation rate limits +- [x] 2.4 Preserve intended public forum reads, document unsupported reactions/reports/moderation/attachments as disabled, and add forum security regression tests + +## 3. Privileged user and organization administration + +- [x] 3.1 Add explicit minimized platform-user summary/detail and narrow role-update request schemas that forbid sensitive extra fields +- [x] 3.2 Enforce global actor/target role allowlists, role hierarchy, no self-role change/delete, no self-escalation, and platform-only role grants +- [x] 3.3 Protect the final usable super administrator transactionally across demotion and deletion, with multiple-admin success cases and actionable conflict responses +- [x] 3.4 Make public registration authority server-controlled and reject/ignore caller privilege assignment safely +- [x] 3.5 Harden organization membership/invitation role grants, active membership/switch scope, token response exposure, and cross-organization behavior +- [x] 3.6 Add privacy-safe structured events and rate limits for privileged account, role, invitation, and cross-organization decisions +- [x] 3.7 Add privileged-user, administrator-safety, mass-assignment, response-minimization, and organization-isolation backend tests + +## 4. Rate limiting and uploads + +- [x] 4.1 Implement environment-configurable centralized fixed-window policies, direct-peer/user/account keys, independent windows, reset behavior, 429 responses, and retry metadata +- [x] 4.2 Apply limits to login, registration, invite acceptance/creation, uploads, forum writes, and user-management mutations without account-existence disclosure +- [x] 4.3 Add image signature/dimension validation, server-controlled paths, explicit format/size rejection, and cleanup while retaining safe existing upload behavior +- [x] 4.4 Add rate-limit configuration/proxy/failure tests and upload signature, traversal, authorization, size, and format regression tests + +## 5. Object authorization and frontend alignment + +- [x] 5.1 Audit client-supplied object identifiers across users, organizations, memberships/invites, sections, content, progress, badges/certificates, forum, uploads, reviews, and reports; fix evidenced high-risk parent/organization gaps narrowly +- [x] 5.2 Add two-organization multi-role backend tests for members, invites, sections, learner data, assignments/resources, and direct-object/parent-child mismatches +- [x] 5.3 Standardize security-relevant 401, 403, concealed 404, 409, 422, and 429 responses without internal leakage +- [x] 5.4 Update Angular admin schemas/actions and accessible error handling for final-admin/self-lockout, reduced responses, uploads, and rate limits +- [x] 5.5 Add Angular tests for 401/403/404/429 handling, reduced schemas, inaccessible forum mutations, administrator validation, and accessible upload/security errors +- [x] 5.6 Add stable Playwright coverage for anonymous forum mutation rejection, organization isolation, lower-role platform denial, final-admin protection, accessible throttling, and direct protected routes where practical + +## 6. Documentation and verification + +- [x] 6.1 Complete all required Phase 8 security policy, audit, response, organization-isolation, event, upload, rate-limit, and administrator-control documents +- [x] 6.2 Update `SECURITY.md`, `ARCHITECTURE.md`, `README.md`, `ROADMAP.md`, and relevant platform-maturity/UX canonical documents with links and deferred work +- [x] 6.3 Run backend format/lint and the complete backend suite without collection regression +- [x] 6.4 Run frontend format/lint, complete Angular tests, production build, and supported dependency audit without collection regression +- [x] 6.5 Run the complete Playwright suite, clean temporary services/data/artifacts, and record stable results +- [x] 6.6 Run strict OpenSpec validation and `git diff --check`, confirm no secrets/temp QA data/unrelated feature work, and complete `docs/security/phase-8-security-verification.md` diff --git a/openspec/changes/unify-course-authoring-experience/.openspec.yaml b/openspec/changes/unify-course-authoring-experience/.openspec.yaml new file mode 100644 index 0000000..e08b5f8 --- /dev/null +++ b/openspec/changes/unify-course-authoring-experience/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-03 diff --git a/openspec/changes/unify-course-authoring-experience/design.md b/openspec/changes/unify-course-authoring-experience/design.md new file mode 100644 index 0000000..16ce30d --- /dev/null +++ b/openspec/changes/unify-course-authoring-experience/design.md @@ -0,0 +1,103 @@ +## Context + +EchoEd has one canonical course, unit, lesson, and activity hierarchy, but authoring is exposed through two incomplete paths. `StudioCoursesComponent` lets content and organization administrators create empty records through the legacy course endpoint, while `CourseWizardComponent` edits the nested graph through routes and APIs limited to platform-level `admin` and `teacher` roles. A separate organization-scoped authoring endpoint and course-version model already exist, but they are not connected to the nested editor. Governance summaries, staff preview, review state, and safe-publish checks also exist outside the creation journey. + +The stakeholders are content administrators who build canonical curriculum, organization administrators who manage scoped offerings, teachers or instructors who may adapt content within policy, reviewers and publishers who approve release, platform administrators who oversee the system, and learners whose delivery view must never expose drafts. The implementation must extend existing routes, models, schemas, role guards, organization membership rules, version records, and governance services rather than create parallel curriculum or approval engines. + +## Goals / Non-Goals + +**Goals:** + +- Provide one discoverable, responsive course studio from creation through governed release. +- Make backend capability and organization scope authoritative while keeping frontend navigation consistent with those decisions. +- Persist the complete canonical course graph safely through autosave and explicit save, with visible state, conflict detection, and idempotent creation. +- Support fast outline construction, inline editing, duplication, deterministic ordering, quality guidance, staff preview, review submission, and safe publication. +- Reuse canonical metadata, source attribution, assessment, governance, and version structures. +- Meet WCAG 2.2 AA interaction expectations, including keyboard operation and non-drag reorder alternatives. +- Introduce bounded template, duplication, and import/export extension points without blocking the core authoring release. + +**Non-Goals:** + +- No new curriculum hierarchy, learner runtime, review engine, permission store, assessment engine, or media store. +- No simultaneous multi-user editing or real-time cursor collaboration in the initial release. +- No AI-generated course execution in this change. +- No claim of Common Cartridge, QTI, LTI, SCORM, Quality Matters, or OSCQR certification. +- No automatic publication merely because a course passes automated checks. + +## Decisions + +### Initial authorization policy + +The first implementation uses the following explicit policy. Backend-derived capabilities remain authoritative, and a future organization policy layer may narrow these defaults without changing the editor contract. + +| Actor | Initial course-authoring policy | +| --- | --- | +| `content_admin` organization member | Create organization drafts; edit organization drafts; duplicate permitted courses; preview; submit for review. Cannot approve or publish. | +| `org_admin` or organization `super_admin` member | All content-admin actions plus review decisions and governed publication within that organization. | +| `teacher` or `instructor` organization member | Duplicate an approved or published permitted course into an organization-scoped derivative; edit drafts they created; preview; submit for review. Cannot edit canonical published content in place, approve, or publish. | +| Platform `admin` or `super_admin` | Platform oversight plus explicit create, edit, duplicate, preview, review, and publish authority for migration compatibility and incident response. | +| Learner, parent, viewer, inactive or invited member | No authoring, review, or publication actions. | + +Review independence is enforced by preventing content administrators and teachers from approving their own submissions. Organization administrators are the initial reviewer role because the current organization-role enum has no separate reviewer role. Adding a dedicated reviewer role remains a later policy enhancement. + +1. **Use one organization-aware aggregate authoring contract over the canonical course graph.** + - The authoring API will create, read, and update course metadata together with ordered units, lessons, activities, sources, and assessment references using existing domain models and route modules. + - Existing learner reads remain audience-filtered and unchanged. Legacy course create/update routes remain temporarily as compatibility adapters and are removed from frontend authoring navigation after migration. + - Alternative: keep shell creation and nested editing as separate workflows. Rejected because it preserves permission drift, duplicate records, and unclear ownership. + +2. **Represent authorization as backend-derived capabilities within organization scope.** + - Responses needed by Studio will expose bounded actions such as `can_create`, `can_edit`, `can_submit_review`, `can_review`, and `can_publish`; the backend remains authoritative for every mutation. + - Default policy: content administrators author and submit; organization administrators author, assign, and publish where organization policy permits; teachers or instructors adapt assigned or permitted content; reviewers decide review outcomes; platform administrators oversee and may act only where explicitly authorized. + - Alternative: reproduce role-name arrays in each Angular route. Rejected because the current drift demonstrates that route-only role lists are not reliable authorization. + +3. **Use a server-authoritative draft with optimistic concurrency and idempotent creation.** + - Initial creation returns a durable draft ID. Subsequent debounced autosaves and explicit saves send a revision token; stale updates return a conflict outcome rather than silently overwriting newer work. + - Creation uses an idempotency key so retries cannot create duplicate drafts. The editor displays saving, saved, offline or failed, and conflicted states and keeps unsaved input available for retry. + - Alternative: retain a singleton client-side `BehaviorSubject` until final POST. Rejected because refresh, navigation, multiple tabs, and network failures can lose or duplicate work. + +4. **Use a persistent outline editor with focused detail panels.** + - Course structure remains visible while creators add or edit units, lessons, and activities. Setup, build, quality, preview, and release are modes over the same durable draft rather than disposable wizard pages. + - Reordering supports drag and drop plus Move up, Move down, and position controls. Destructive actions require contextual confirmation and preserve deterministic ordering. + - Alternative: extend the existing four-step wizard. Rejected because activity creation is currently hidden in review, previous context disappears, and creators cannot efficiently revise the overall structure. + +5. **Separate authoring validation from governed release decisions.** + - Drafts may remain incomplete. Inline validation explains field-level problems, while a quality panel aggregates completeness, measurable objectives, objective/activity/assessment alignment, source and media issues, accessibility prompts, and canonical governance readiness. + - Submission and publication use existing review and safe-publish semantics. Automated checks inform authorized humans and never bypass review authority. + +6. **Make staff preview use learner serialization without learner availability.** + - Preview renders the learner-safe projection of the selected durable draft or version for authorized staff, excluding educator-only fields exactly as learner delivery does, but without making the content learner-visible. + - Alternative: preview the authoring object directly. Rejected because it would not expose audience-filtering defects before release. + +7. **Treat templates and exchange formats as adapters at the boundary.** + - Blank creation, internal templates, and course duplication normalize into the same authoring aggregate. Import/export adapters also map to this aggregate and return a validation report before persistence. + - The first slice may deliver blank, duplicate, and internal-template creation while defining interfaces and tests for later Common Cartridge and QTI adapters. + +8. **Retire authoring controls from oversight-only pages.** + - Admin course oversight continues to expose governance and high-impact administrative actions. All create/edit links route to the canonical Studio only when the capability payload permits them. + - The shallow Studio draft form and legacy wizard routes are removed after migration and deep links redirect to the corresponding Studio draft. + +## Risks / Trade-offs + +- **Nested aggregate saves could become large or slow** → Use bounded payloads, transactional persistence, normalized client state, and later incremental entity endpoints only where profiling demonstrates need. +- **Autosave could create write contention or stale overwrites** → Debounce changes, serialize writes per draft, use revision tokens, and provide an explicit conflict-resolution path. +- **Role policy may differ between organizations** → Centralize capability calculation and keep organization publishing policy configurable rather than hard-code frontend role assumptions. +- **Legacy and new routes may diverge during migration** → Make legacy writes call the same domain service, add contract tests, instrument remaining legacy usage, and time-box compatibility. +- **Quality guidance could overwhelm occasional authors** → Use progressive disclosure, plain-language explanations, direct links to the affected item, and separate blocking issues from recommendations. +- **Import complexity could expand scope** → Ship boundary interfaces and validation reports first; treat certified format coverage as separate follow-on work. +- **Course graph deletion can disrupt active learners** → Prefer archive and version replacement in the Studio; preserve existing permanent deletion only in explicit administrative oversight until a recoverable archive contract is complete. + +## Migration Plan + +1. Add capability calculation, draft ownership or revision metadata where absent, idempotent creation, and transactional aggregate save services behind existing course route modules. +2. Add role-matrix and course-graph contract tests before changing navigation. +3. Build the canonical Studio editor and point content-admin and organization-admin entry points to it behind a feature flag. +4. Connect quality, preview, review submission, versioning, and safe publish; verify learner isolation throughout. +5. Migrate legacy editor deep links to Studio, retain compatibility adapters for one release window, and measure remaining use. +6. Remove the shallow Studio form and legacy wizard navigation after data and journey verification; preserve rollback by re-enabling the feature flag and adapters. +7. Add template and duplication flows, then deliver import/export adapters as bounded follow-on slices. + +## Open Questions + +- What conflict-resolution UX is acceptable for simultaneous edits from two browser tabs before real-time collaboration exists? +- Which initial templates are product-owned, and who can create organization-specific templates? +- Is Common Cartridge import/export required for the first production release or only the adapter contract and internal template format? diff --git a/openspec/changes/unify-course-authoring-experience/proposal.md b/openspec/changes/unify-course-authoring-experience/proposal.md new file mode 100644 index 0000000..78e5dd0 --- /dev/null +++ b/openspec/changes/unify-course-authoring-experience/proposal.md @@ -0,0 +1,34 @@ +## Why + +EchoEd currently splits course creation across a shallow Studio draft form and a legacy four-step editor whose routes and APIs exclude the content-administrator roles expected to own authoring. A unified, low-friction course studio is needed so permitted creators can safely build, review, preview, and submit complete courses without navigating conflicting workflows or risking lost and duplicate drafts. + +## What Changes + +- Establish one canonical course-authoring entry point for content administrators, organization administrators, teachers or instructors, reviewers, and platform administrators, with actions determined by explicit role and organization scope. +- Replace the disconnected shell-creation and legacy wizard experiences with a resumable course studio covering setup, outline, lesson and activity construction, quality checks, learner preview, review submission, and governed publishing. +- Add real draft semantics: autosave, visible save state, exit-and-resume behavior, duplicate-submission protection, recoverable failures, and version-aware edits. +- Add guided course setup for audience, subject, age or grade range, locale, learning objectives, skills, and standards metadata using existing canonical course structures. +- Add an editable course outline with inline unit, lesson, and activity creation; deterministic ordering; duplication; and accessible non-drag reordering controls. +- Integrate validation, objective alignment, accessibility guidance, governance readiness, and learner preview before review or publication. +- Add templates, course duplication, and a bounded import/export foundation, including a path toward Common Cartridge and QTI interoperability without making external standards a prerequisite for the first implementation slice. +- Preserve one canonical curriculum hierarchy and learner-delivery governance model; no parallel course, unit, lesson, activity, review, or publishing system will be introduced. + +## Capabilities + +### New Capabilities + +- `course-authoring-experience`: Defines the unified, role-aware, accessible, autosaving course-studio workflow and its authoring, quality, preview, and recovery behavior. + +### Modified Capabilities + +- `course-and-lesson-api-contracts`: Extends authoring-capable course and lesson contracts so permitted organization-scoped creators can create and update the complete canonical course graph through consistent APIs. +- `role-based-content-visibility`: Defines distinct author, reviewer, publisher, and oversight actions while preserving learner-safe and educator-only field visibility. +- `pathway-course-publishing-governance`: Integrates draft lifecycle, preview, readiness feedback, review submission, versioning, and permission-controlled publication into the authoring workflow. + +## Impact + +- Frontend: Angular workspace and Studio routes, course listing and editor surfaces, course wizard/service code, shared forms, status messaging, responsive behavior, and accessibility tests. +- Backend: existing course routes, schemas, organization-role dependencies, course-version and governance services, nested course-graph update behavior, and audit-safe publishing endpoints. +- Data: canonical course, unit, lesson, activity, source, version, organization, and governance records remain authoritative; migrations may be required only for explicit draft ownership, autosave revision, or template metadata not already represented. +- Tests: role-matrix API coverage, nested graph persistence, draft/version behavior, validation, keyboard and non-drag interactions, full author journeys, preview isolation, review submission, and safe publish behavior. +- Interoperability: establishes extension points for Common Cartridge course exchange and QTI assessment exchange; initial implementation must not claim certification without conformance testing. diff --git a/openspec/changes/unify-course-authoring-experience/specs/course-and-lesson-api-contracts/spec.md b/openspec/changes/unify-course-authoring-experience/specs/course-and-lesson-api-contracts/spec.md new file mode 100644 index 0000000..c282067 --- /dev/null +++ b/openspec/changes/unify-course-authoring-experience/specs/course-and-lesson-api-contracts/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: Authoring APIs persist the complete canonical course graph consistently +The platform SHALL provide authorized authoring contracts that create, retrieve, and transactionally update canonical course metadata, units, lessons, activities, sources, and assessment references within organization scope while preserving learner-facing API behavior. + +#### Scenario: Authorized creator creates a durable draft +- **WHEN** an authorized creator submits valid minimum course identity with an idempotency key +- **THEN** the authoring API creates one organization-scoped draft, returns its identifier and revision, and returns the same result for a safe retry of the same creation request + +#### Scenario: Authorized creator saves a nested course graph +- **WHEN** an authorized creator submits a valid course graph update against the current revision +- **THEN** the API persists the nested change transactionally, maintains deterministic ordering, and returns the next revision + +#### Scenario: Nested graph persistence fails +- **WHEN** any required part of a course-graph mutation fails validation or persistence +- **THEN** the API returns structured issue context and does not commit a partial graph update + +#### Scenario: Learner reads the updated course +- **WHEN** a learner requests the course after an authoring update +- **THEN** the existing audience-aware and publication-aware learner contract remains authoritative and excludes non-deliverable content + +### Requirement: Authoring APIs protect concurrent revisions +The platform SHALL require a current revision token for updates to durable course drafts and SHALL return an explicit conflict response for stale writes. + +#### Scenario: Creator saves a stale revision +- **WHEN** an authoring update references a revision older than the current server revision +- **THEN** the API rejects the update without overwriting newer work and returns enough bounded metadata for the client to reload or recover + +### Requirement: Legacy authoring routes converge on the canonical authoring service +During migration, any retained legacy course creation or update route SHALL enforce equivalent authorization, validation, transactional persistence, and ordering through the canonical authoring domain service. + +#### Scenario: Legacy client creates or edits a course during migration +- **WHEN** a supported legacy client invokes a retained course write route +- **THEN** the route delegates to the canonical authoring service and cannot bypass organization scope, idempotency, validation, or governance invariants + diff --git a/openspec/changes/unify-course-authoring-experience/specs/course-authoring-experience/spec.md b/openspec/changes/unify-course-authoring-experience/specs/course-authoring-experience/spec.md new file mode 100644 index 0000000..81aec3e --- /dev/null +++ b/openspec/changes/unify-course-authoring-experience/specs/course-authoring-experience/spec.md @@ -0,0 +1,124 @@ +## ADDED Requirements + +### Requirement: Permitted creators enter one canonical course studio +The platform SHALL provide one canonical course-authoring experience for every user with an organization-scoped or platform-scoped authoring capability and SHALL NOT require users to choose between disconnected shell-creation and nested-editing workflows. + +#### Scenario: Content administrator starts a course +- **WHEN** a content administrator with active organization authoring permission selects Create course +- **THEN** the platform creates or opens a durable draft in the canonical course studio +- **AND** the same studio supports setup, structure, content, quality, preview, and review submission + +#### Scenario: User lacks authoring permission +- **WHEN** a user without course-authoring capability navigates directly to a course-studio route +- **THEN** the platform denies the authoring action consistently with the backend authorization decision +- **AND** it does not expose mutation controls through alternate routes + +### Requirement: Course creation supports guided starting points +The course studio SHALL support blank creation, internal templates, and permitted course duplication through a common draft-creation contract. + +#### Scenario: Creator starts from a blank course +- **WHEN** a permitted creator chooses Blank course and supplies the minimum required identity +- **THEN** the system creates a durable organization-scoped draft and opens it in the studio + +#### Scenario: Creator duplicates a permitted course +- **WHEN** a permitted creator chooses a course they are authorized to duplicate +- **THEN** the system creates a distinct draft with copied authorable content, regenerated identifiers, preserved source attribution, and no inherited learner enrollments or publication state + +### Requirement: Course setup captures instructional identity and alignment metadata +The course studio SHALL let creators define title, description, subject, audience or age or grade range, locale, course-level learning objectives, skills, and standards metadata supported by the canonical course model. + +#### Scenario: Creator completes course setup +- **WHEN** a creator enters supported instructional identity and alignment metadata +- **THEN** the system persists it to the durable draft and presents it in subsequent quality and preview views + +#### Scenario: Required setup data is missing +- **WHEN** a creator attempts a transition that requires missing or invalid setup data +- **THEN** the studio identifies each affected field in text, explains how to correct it, and moves focus to or links directly to the first issue + +### Requirement: Creators build the canonical course hierarchy in context +The course studio SHALL present a persistent editable outline for canonical units, lessons, activities, source references, and assessment references without introducing a parallel hierarchy. + +#### Scenario: Creator adds nested content +- **WHEN** a creator adds a unit, lesson, or supported activity from the outline +- **THEN** the new item appears in its intended parent with a deterministic order and can be edited without leaving the course context + +#### Scenario: Creator duplicates an outline item +- **WHEN** a creator duplicates a permitted unit, lesson, or activity +- **THEN** the system creates distinct identifiers, preserves authorable content and attribution, and places the duplicate adjacent to the source item with deterministic ordering + +### Requirement: Reordering is efficient and accessible +The course studio SHALL support deterministic reordering through keyboard-operable, single-pointer non-drag controls in addition to any drag-and-drop interaction. + +#### Scenario: Creator reorders without dragging +- **WHEN** a creator activates Move up, Move down, or an equivalent non-drag position control +- **THEN** the item moves to the requested valid position, ordering is renumbered deterministically, and the result is announced as a status message + +#### Scenario: Creator uses drag and drop +- **WHEN** a creator completes a drag-and-drop reorder +- **THEN** the same canonical ordering operation and validation apply as for non-drag reordering + +### Requirement: Draft work is durable and recoverable +The course studio SHALL autosave durable drafts, display current save state, prevent duplicate creation during retries, detect conflicting revisions, and allow failed changes to be retried without discarding the creator's input. + +#### Scenario: Autosave succeeds +- **WHEN** a creator changes a draft and the autosave interval elapses +- **THEN** the system persists the change against the current revision and displays the saved state and time + +#### Scenario: Autosave fails +- **WHEN** a draft save fails because of network or server error +- **THEN** the studio retains the unsaved input, displays a specific failed-save state, and offers retry without creating another course + +#### Scenario: Draft has a conflicting revision +- **WHEN** a save is based on a revision older than the server's current revision +- **THEN** the system rejects silent overwrite and presents a bounded conflict outcome with reload or recovery options + +#### Scenario: Creator exits and resumes +- **WHEN** a creator leaves the studio after a successful save and later reopens the draft +- **THEN** the studio restores the saved course graph, selected draft identity, and current lifecycle state + +### Requirement: Quality guidance is actionable and progressively disclosed +The course studio SHALL distinguish blocking issues from recommendations and SHALL evaluate completeness, objective alignment, assessment coverage, deterministic ordering, accessibility prompts, source attribution, media validity, and canonical governance readiness where data is available. + +#### Scenario: Course has quality issues +- **WHEN** a creator opens the quality view for an incomplete or inconsistent draft +- **THEN** the studio groups issues by severity and course location +- **AND** each issue explains the expected correction and links to the affected item + +#### Scenario: Course has no known blocking issue +- **WHEN** all required automated checks pass +- **THEN** the studio reports that automated checks are satisfied without representing that result as human review approval or certification + +### Requirement: Authorized staff can preview the learner-safe experience +The course studio SHALL provide authorized staff a responsive learner preview of a selected durable draft or version without making it learner-visible. + +#### Scenario: Creator previews a draft +- **WHEN** authorized staff selects learner preview +- **THEN** the system renders the learner-safe serialization and ordered delivery view for that draft or version +- **AND** it suppresses educator-only fields while clearly labeling the experience as a preview + +#### Scenario: Learner requests the previewed draft +- **WHEN** a learner attempts to access content that has only been previewed and not published +- **THEN** canonical learner availability rules continue to return the governed unavailable outcome + +### Requirement: The authoring experience is responsive and WCAG 2.2 AA-oriented +The course studio SHALL provide programmatic labels, logical headings and focus order, keyboard operation, visible focus, text-based error identification, announced save and reorder status, adequately sized controls, and usable layouts across desktop, tablet, and mobile breakpoints. + +#### Scenario: Keyboard user builds course structure +- **WHEN** a creator uses only a keyboard to add, edit, reorder, validate, preview, and submit a course +- **THEN** all required authoring functions remain operable with visible focus and predictable focus movement + +#### Scenario: Assistive technology receives status feedback +- **WHEN** save, validation, upload, reorder, or submission state changes asynchronously +- **THEN** the relevant status is exposed programmatically without forcing an unexpected context change + +### Requirement: Import and export adapters protect canonical data +The platform SHALL define a bounded import/export boundary that maps supported external or internal packages to the canonical authoring aggregate and reports unsupported or invalid content before mutation. + +#### Scenario: Creator imports a supported package +- **WHEN** a permitted creator uploads a supported course package +- **THEN** the system validates it, presents a mapping and issue report, and creates a draft only after explicit confirmation + +#### Scenario: Package contains unsupported content +- **WHEN** an import includes unsupported activity, assessment, metadata, or media constructs +- **THEN** the system reports each unsupported construct and does not silently discard it + diff --git a/openspec/changes/unify-course-authoring-experience/specs/pathway-course-publishing-governance/spec.md b/openspec/changes/unify-course-authoring-experience/specs/pathway-course-publishing-governance/spec.md new file mode 100644 index 0000000..eb005fe --- /dev/null +++ b/openspec/changes/unify-course-authoring-experience/specs/pathway-course-publishing-governance/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Course drafts follow an explicit governed lifecycle +The platform SHALL represent durable course drafts, review submission, review decisions, version identity, and publication as explicit lifecycle transitions over the canonical curriculum and governance systems. + +#### Scenario: Author saves incomplete work +- **WHEN** an authorized author saves a course that is not ready for review or publication +- **THEN** the course remains a durable draft and is not exposed to learners + +#### Scenario: Author submits for review +- **WHEN** an authorized author submits a draft that satisfies submission requirements +- **THEN** the platform records the submitted version, author, timestamp, readiness evidence, and review state without publishing it + +#### Scenario: Reviewer returns a course +- **WHEN** an authorized reviewer requests changes with feedback +- **THEN** the platform records the decision and feedback, returns the course to an editable governed state, and preserves the submitted version history + +#### Scenario: Authorized publisher publishes an approved version +- **WHEN** an authorized publisher selects an approved version that passes canonical safe-publish validation +- **THEN** the platform publishes that immutable version, records the actor and timestamp, and makes only the governed learner-safe projection available + +### Requirement: Publish readiness is actionable from the course studio +The platform SHALL expose canonical publish-readiness and safe-publish findings within the course studio with severity, affected entity, and corrective context while preserving governance services as the source of truth. + +#### Scenario: Readiness check finds a blocking issue +- **WHEN** the canonical readiness evaluation identifies a blocking course, unit, lesson, activity, assessment, source, or ordering issue +- **THEN** the studio identifies the affected entity and prevents governed publication while allowing the draft to remain editable + +#### Scenario: Readiness check passes +- **WHEN** canonical automated readiness and safe-publish checks pass +- **THEN** the platform enables only the next lifecycle actions allowed by the user's capability and does not bypass required human review + +### Requirement: Published learner access remains isolated from draft edits +The platform SHALL keep the currently published immutable version learner-visible while authors prepare later drafts or revisions. + +#### Scenario: Author edits a published course +- **WHEN** an authorized author begins changing a published course +- **THEN** the system creates or opens a new draft version and preserves the currently published learner experience until a later version is approved and published + +#### Scenario: New version replaces published version +- **WHEN** an authorized publisher releases a later approved version +- **THEN** new learner delivery resolves to the new published version while historical version and audit records remain available to authorized staff + diff --git a/openspec/changes/unify-course-authoring-experience/specs/role-based-content-visibility/spec.md b/openspec/changes/unify-course-authoring-experience/specs/role-based-content-visibility/spec.md new file mode 100644 index 0000000..0f8d497 --- /dev/null +++ b/openspec/changes/unify-course-authoring-experience/specs/role-based-content-visibility/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: Course-authoring actions are capability-based and organization-scoped +The platform SHALL derive course-authoring actions from the authenticated user's platform role, active organization membership, course relationship, and organization policy, and SHALL enforce those actions on the backend for every mutation. + +#### Scenario: Content administrator authors organization curriculum +- **WHEN** an active content administrator creates or edits a course within an authorized organization +- **THEN** the platform permits authoring and review submission while withholding review or publication actions not granted by policy + +#### Scenario: Organization administrator manages a course +- **WHEN** an active organization administrator acts on a course within the administered organization +- **THEN** the platform exposes only the authoring, assignment, review, or publication actions allowed by organization policy + +#### Scenario: Teacher adapts curriculum +- **WHEN** a teacher or instructor is permitted to adapt an assigned or approved course +- **THEN** the platform applies the configured derivative-copy or version workflow without implicitly granting canonical publication authority + +#### Scenario: Reviewer evaluates submitted content +- **WHEN** a reviewer opens a course submitted within the reviewer's authorized scope +- **THEN** the platform permits the configured review decision and feedback actions without implicitly granting authoring ownership + +#### Scenario: Frontend capability state is stale or manipulated +- **WHEN** a client sends an authoring, review, or publication mutation that the backend capability decision does not allow +- **THEN** the backend rejects the mutation without changing canonical content or lifecycle state + +### Requirement: Authoring capability discovery is consistent across navigation and APIs +The platform SHALL provide a bounded capability representation that lets authorized clients render course actions consistently without treating client-side route guards as authorization. + +#### Scenario: User opens a course collection +- **WHEN** the course collection or course detail loads for authenticated staff +- **THEN** the response or associated capability contract identifies permitted create, edit, duplicate, submit, review, preview, and publish actions for the relevant scope + +#### Scenario: User follows a deep link +- **WHEN** a user follows an authoring deep link +- **THEN** route access, rendered controls, and backend mutation decisions resolve from the same canonical capability rules + diff --git a/openspec/changes/unify-course-authoring-experience/tasks.md b/openspec/changes/unify-course-authoring-experience/tasks.md new file mode 100644 index 0000000..cd54d6c --- /dev/null +++ b/openspec/changes/unify-course-authoring-experience/tasks.md @@ -0,0 +1,90 @@ +## 1. Authorization and Existing-System Baseline + +- [x] 1.1 Document the initial organization policy for content-admin authoring, org-admin publication, teacher derivative copies, reviewer decisions, and platform-admin oversight in the change design before behavior is enabled. +- [x] 1.2 Add a role-and-organization-scope test matrix covering create, view draft, edit, duplicate, preview, submit, review, publish, and denied mutations. +- [x] 1.3 Inventory canonical course, unit, lesson, activity, source, assessment, version, review, and publication fields and map both existing creation paths to that model. +- [x] 1.4 Add regression tests proving learner course and lesson reads continue to exclude drafts and educator-only fields throughout authoring changes. + +## 2. Backend Capability Contract + +- [x] 2.1 Implement one reusable backend course-authoring capability resolver based on platform role, active organization membership, course relationship, and organization policy. +- [x] 2.2 Add bounded capability response schemas for create, edit, duplicate, preview, submit, review, and publish actions. +- [x] 2.3 Expose collection-scope and course-scope capabilities through existing course route modules. +- [x] 2.4 Replace direct role checks on authoring mutations with the capability resolver while preserving explicit learner denial. +- [x] 2.5 Add backend tests proving manipulated or stale client capability state cannot authorize a mutation. + +## 3. Durable Draft and Aggregate Persistence + +- [x] 3.1 Add or migrate draft ownership, organization scope, lifecycle state, revision, save timestamp, and template-origin metadata that are not already represented canonically. +- [x] 3.2 Implement a transactional aggregate authoring service that creates and updates course metadata, ordered units, lessons, activities, sources, and assessment references through existing models. +- [x] 3.3 Implement deterministic order normalization and validation for every nested collection mutation. +- [x] 3.4 Implement idempotent draft creation and test that safe retries return one durable draft. +- [x] 3.5 Implement optimistic revision checks and a structured conflict response that prevents stale overwrites. +- [x] 3.6 Add rollback tests proving invalid nested updates never commit a partial course graph. + +## 4. Canonical Authoring APIs and Legacy Convergence + +- [x] 4.1 Extend the existing organization-aware authoring routes and schemas to read and persist the complete authoring aggregate and current revision. +- [x] 4.2 Add structured validation issues containing severity, entity type, entity ID, field, message, and corrective context. +- [x] 4.3 Add duplication operations for courses, units, lessons, and activities with regenerated identifiers, preserved attribution, and no inherited enrollment or publication state. +- [x] 4.4 Route retained legacy create and update endpoints through the aggregate service with equivalent authorization, idempotency, validation, and transaction behavior. +- [x] 4.5 Add contract tests for new and legacy routes to prevent graph, permission, and learner-serialization drift during migration. + +## 5. Canonical Studio Shell and Navigation + +- [x] 5.1 Add typed Angular models and service methods for authoring aggregates, revisions, lifecycle state, capabilities, validation issues, and save conflicts. +- [x] 5.2 Build the canonical responsive course-studio shell with persistent outline, focused editor area, and Setup, Build, Quality, Preview, and Release modes. +- [x] 5.3 Replace hard-coded authoring role arrays with route entry behavior backed by the capability contract and backend enforcement. +- [x] 5.4 Point content-admin, organization-admin, and permitted teacher or admin course entry actions to the canonical Studio routes. +- [x] 5.5 Add loading, permission-denied, not-found, empty, and recoverable route-failure states with logical focus placement. + +## 6. Guided Setup and Outline Authoring + +- [x] 6.1 Implement blank-course setup for title, description, subject, audience or age or grade range, locale, learning objectives, skills, and standards metadata. +- [x] 6.2 Implement inline unit, lesson, and activity creation and editing against the canonical draft graph. +- [x] 6.3 Implement course, unit, lesson, and activity duplication controls gated by returned capabilities. +- [x] 6.4 Implement deterministic drag-and-drop reordering using the same state operation as keyboard-operable Move up, Move down, and position controls. +- [x] 6.5 Add contextual destructive-action confirmation and ensure removals preserve valid ordering and focus. +- [x] 6.6 Integrate existing media upload, source attribution, assessment references, and supported activity types without introducing duplicate services. + +## 7. Autosave, Recovery, and Conflict UX + +- [x] 7.1 Implement debounced serialized autosave for durable drafts and an explicit Save now action using revision tokens. +- [x] 7.2 Display Saving, Saved with time, Save failed, Offline, and Conflict states through accessible status messaging. +- [x] 7.3 Retain unsaved client input after save failure and provide a retry path that cannot create a duplicate course. +- [x] 7.4 Implement exit-and-resume behavior and a navigation warning only while local changes are not durably saved. +- [x] 7.5 Implement bounded conflict recovery that lets a creator reload the server version or preserve recoverable local content without silently overwriting either revision. +- [x] 7.6 Add component and service tests for debounce, serialized writes, retries, duplicate prevention, navigation, refresh, and multi-tab conflict responses. + +## 8. Quality, Preview, Review, and Publishing + +- [x] 8.1 Build progressive field-level validation with text explanations, direct issue links, and focus movement to the first blocking problem. +- [x] 8.2 Build the quality panel by combining completeness, objective alignment, assessment coverage, ordering, accessibility prompts, source and media checks, and canonical governance findings. +- [x] 8.3 Connect issue actions to the affected course, unit, lesson, activity, source, or assessment editor without losing draft context. +- [x] 8.4 Implement staff learner preview using learner-safe serialization of the selected durable draft or version without changing learner availability. +- [x] 8.5 Connect submit-for-review, review feedback and decisions, version history, readiness refresh, and safe publish to existing governance services. +- [x] 8.6 Ensure lifecycle actions render only when capabilities allow them and that automated quality success never bypasses required human review. +- [x] 8.7 Add end-to-end tests proving draft isolation, learner-safe preview, review return, approved publication, and published-version stability during later edits. + +## 9. Accessibility and Responsive Verification + +- [x] 9.1 Associate every form label, instruction, description, and error programmatically and expose asynchronous save, upload, reorder, and submission states to assistive technology. +- [x] 9.2 Verify logical headings, focus order, visible focus, keyboard-only completion, minimum target sizing, and non-drag alternatives across the complete author journey. +- [x] 9.3 Verify setup, outline editing, activity authoring, quality checks, preview, and release remain usable at desktop, tablet, and mobile breakpoints. +- [x] 9.4 Add automated accessibility checks where supported and record a manual keyboard and screen-reader verification checklist for behaviors automation cannot prove. + +## 10. Templates, Duplication, and Exchange Boundary + +- [x] 10.1 Implement internal template discovery and template-based draft creation through the same idempotent aggregate creation contract. +- [x] 10.2 Add template and whole-course duplication tests for regenerated identifiers, source attribution, organization scope, and excluded learner or publication state. +- [x] 10.3 Define import and export adapter interfaces and a validation-report schema that identify every unsupported construct before persistence. +- [x] 10.4 Implement the agreed first-release import or export slice, or explicitly defer format execution while retaining tested adapter boundaries for Common Cartridge and QTI follow-on work. + +## 11. Migration and Release Verification + +- [x] 11.1 Place canonical Studio navigation behind a feature flag and verify authorized pilot users can complete a realistic course from blank draft through review submission. +- [x] 11.2 Redirect legacy wizard deep links to the corresponding Studio draft while retaining compatibility adapters for the agreed migration window. +- [x] 11.3 Remove the shallow Studio draft form and legacy wizard navigation after usage, data-integrity, and rollback checks pass. +- [x] 11.4 Run focused backend and frontend tests, full relevant suites, production build, OpenSpec validation, and course-authoring browser smoke tests. +- [x] 11.5 Capture performance for representative large course graphs, verify autosave payload and latency bounds, and address regressions before general release. +- [x] 11.6 Document the final role policy, creator workflow, recovery behavior, review and publishing responsibilities, known interoperability limits, and rollback procedure.