From 66eac3c3313c6a720b0ea12891f7f7b0332137f6 Mon Sep 17 00:00:00 2001 From: leonshimizu Date: Sat, 25 Jul 2026 12:41:38 +1000 Subject: [PATCH 1/4] Harden FDMS classroom workflows --- .github/dependabot.yml | 22 + .github/workflows/ci.yml | 89 + .node-version | 1 + README.md | 1 + api/.env.example | 6 + api/.github/workflows/ci.yml | 90 - api/Gemfile | 1 + api/Gemfile.lock | 92 +- .../v1/organization_invitations_controller.rb | 10 +- .../api/v1/organizations_controller.rb | 336 ++- .../api/v1/project_checkpoints_controller.rb | 8 + .../api/v1/project_comments_controller.rb | 117 + .../api/v1/project_shares_controller.rb | 33 +- .../controllers/api/v1/projects_controller.rb | 70 +- api/app/controllers/application_controller.rb | 11 + api/app/controllers/concerns/authorizable.rb | 8 + .../concerns/clerk_authenticatable.rb | 3 +- api/app/jobs/cleanup_expired_data_job.rb | 10 + api/app/jobs/organization_invite_email_job.rb | 43 + api/app/models/api_rate_limit.rb | 28 + api/app/models/audit_event.rb | 17 + api/app/models/organization.rb | 6 + api/app/models/organization_invitation.rb | 21 + api/app/models/project.rb | 20 + api/app/models/project_checkpoint.rb | 9 + api/app/models/project_comment.rb | 32 + api/app/models/project_comment_read.rb | 7 + api/app/models/project_file.rb | 7 + api/app/models/project_share.rb | 7 + api/app/models/user.rb | 3 + .../organization_invite_email_service.rb | 16 +- api/bin/jobs | 6 + api/config/environments/production.rb | 4 +- api/config/initializers/app_origin.rb | 4 + api/config/initializers/cors.rb | 5 +- api/config/puma.rb | 3 + api/config/queue.yml | 18 + api/config/recurring.yml | 8 + api/config/routes.rb | 16 +- ..._classroom_feedback_and_project_locking.rb | 29 + ...5000002_harden_organization_invitations.rb | 49 + ...0260725000003_create_solid_queue_tables.rb | 142 + ...dd_classroom_lifecycle_to_organizations.rb | 7 + .../20260725000005_create_audit_events.rb | 15 + .../20260725000006_create_api_rate_limits.rb | 13 + api/db/schema.rb | 202 +- .../classroom_feedback_api_test.rb | 110 + api/test/integration/projects_api_test.rb | 400 ++- .../jobs/cleanup_expired_data_job_test.rb | 44 + .../organization_invite_email_job_test.rb | 57 + api/test/models/api_rate_limit_test.rb | 15 + api/test/models/audit_event_test.rb | 10 + api/test/models/project_quota_test.rb | 31 + docs/FDMS_CLASSROOM_LAUNCH_PLAN.md | 619 ++++ web/index.html | 12 +- web/package-lock.json | 2614 +++++++++++++---- web/package.json | 8 +- web/public/robots.txt | 4 +- web/public/sitemap.xml | 2 +- web/src/App.css | 192 +- web/src/App.tsx | 512 +++- web/src/components/ProjectFeedback.test.tsx | 74 + web/src/components/ProjectFeedback.tsx | 163 + web/src/components/WebPreview.tsx | 2 +- web/src/hooks/useModalFocus.test.tsx | 50 + web/src/hooks/useModalFocus.ts | 63 + web/src/lib/api.ts | 152 +- web/src/lib/cloudSyncStorage.test.ts | 25 + web/src/lib/cloudSyncStorage.ts | 48 + web/src/lib/codeRunner.ts | 1 + web/src/lib/projectStorage.ts | 14 +- web/src/lib/workspace.test.ts | 102 + web/src/lib/workspace.ts | 32 +- web/src/main.tsx | 10 +- web/src/test/setup.ts | 6 + web/vite.config.ts | 24 +- 76 files changed, 6179 insertions(+), 862 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .node-version delete mode 100644 api/.github/workflows/ci.yml create mode 100644 api/app/controllers/api/v1/project_comments_controller.rb create mode 100644 api/app/jobs/cleanup_expired_data_job.rb create mode 100644 api/app/jobs/organization_invite_email_job.rb create mode 100644 api/app/models/api_rate_limit.rb create mode 100644 api/app/models/audit_event.rb create mode 100644 api/app/models/project_comment.rb create mode 100644 api/app/models/project_comment_read.rb create mode 100755 api/bin/jobs create mode 100644 api/config/initializers/app_origin.rb create mode 100644 api/config/queue.yml create mode 100644 api/config/recurring.yml create mode 100644 api/db/migrate/20260725000001_add_classroom_feedback_and_project_locking.rb create mode 100644 api/db/migrate/20260725000002_harden_organization_invitations.rb create mode 100644 api/db/migrate/20260725000003_create_solid_queue_tables.rb create mode 100644 api/db/migrate/20260725000004_add_classroom_lifecycle_to_organizations.rb create mode 100644 api/db/migrate/20260725000005_create_audit_events.rb create mode 100644 api/db/migrate/20260725000006_create_api_rate_limits.rb create mode 100644 api/test/integration/classroom_feedback_api_test.rb create mode 100644 api/test/jobs/cleanup_expired_data_job_test.rb create mode 100644 api/test/jobs/organization_invite_email_job_test.rb create mode 100644 api/test/models/api_rate_limit_test.rb create mode 100644 api/test/models/audit_event_test.rb create mode 100644 api/test/models/project_quota_test.rb create mode 100644 docs/FDMS_CLASSROOM_LAUNCH_PLAN.md create mode 100644 web/src/components/ProjectFeedback.test.tsx create mode 100644 web/src/components/ProjectFeedback.tsx create mode 100644 web/src/hooks/useModalFocus.test.tsx create mode 100644 web/src/hooks/useModalFocus.ts create mode 100644 web/src/lib/cloudSyncStorage.test.ts create mode 100644 web/src/lib/cloudSyncStorage.ts create mode 100644 web/src/lib/workspace.test.ts create mode 100644 web/src/test/setup.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6bb4c5c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: bundler + directory: /api + schedule: + interval: weekly + groups: + ruby-security-and-maintenance: + patterns: ["*"] + + - package-ecosystem: npm + directory: /web + schedule: + interval: weekly + groups: + frontend-security-and-maintenance: + patterns: ["*"] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..475238b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,89 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: api + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: hafa_code_test + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + env: + RAILS_ENV: test + DATABASE_URL: postgres://postgres:postgres@localhost:5432/hafa_code_test + RUBOCOP_CACHE_ROOT: tmp/rubocop + + steps: + - uses: actions/checkout@v6 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: api/.ruby-version + bundler-cache: true + working-directory: api + + - name: Prepare database + run: bin/rails db:prepare + + - name: Test + run: bin/rails test + + - name: Ruby style + run: bin/rubocop --format github + + - name: Static security scan + run: bin/brakeman --no-pager + + - name: Dependency security audit + run: bundle exec bundler-audit check --update + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: .node-version + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Test + run: npm test + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + + - name: Dependency security audit + run: npm audit --audit-level=high diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..941d7c0 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22.22.3 diff --git a/README.md b/README.md index 3928f8c..fb926a2 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ hafa-code/ - [Security model](docs/SECURITY.md) - [Multi-file workspace plan](docs/MULTI_FILE_WORKSPACE.md) - [Classroom, orgs, sharing, accessibility, and runner plan](docs/CLASSROOM_ORGS_AND_SHARING_PLAN.md) +- [FDMS classroom launch readiness and action plan](docs/FDMS_CLASSROOM_LAUNCH_PLAN.md) ## Security Model diff --git a/api/.env.example b/api/.env.example index 48ab175..d849d60 100644 --- a/api/.env.example +++ b/api/.env.example @@ -1,6 +1,8 @@ DATABASE_URL=postgres://localhost/hafa_code_development FRONTEND_URL=http://localhost:5173 ALLOWED_ORIGINS=http://localhost:5173 +SOLID_QUEUE_IN_PUMA=true +JOB_CONCURRENCY=1 CLERK_ISSUER=https://example.clerk.accounts.dev CLERK_JWKS_URL=https://example.clerk.accounts.dev/.well-known/jwks.json @@ -8,3 +10,7 @@ CLERK_AUDIENCE= CLERK_SECRET_KEY=sk_test_replace_me OWNER_ADMIN_EMAILS= ALLOW_OPEN_SIGNUPS=true +ALLOWED_MEMBER_EMAIL_DOMAINS= +ALLOW_ORGANIZATION_EXTERNAL_SHARING=false +RESEND_API_KEY= +RESEND_FROM_EMAIL= diff --git a/api/.github/workflows/ci.yml b/api/.github/workflows/ci.yml deleted file mode 100644 index f4006f4..0000000 --- a/api/.github/workflows/ci.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: [ main ] - -jobs: - scan_ruby: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true - - - name: Scan for common Rails security vulnerabilities using static analysis - run: bin/brakeman --no-pager - - - name: Scan for known security vulnerabilities in gems used - run: bin/bundler-audit - - lint: - runs-on: ubuntu-latest - env: - RUBOCOP_CACHE_ROOT: tmp/rubocop - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true - - - name: Prepare RuboCop cache - uses: actions/cache@v4 - env: - DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }} - with: - path: ${{ env.RUBOCOP_CACHE_ROOT }} - key: rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }} - restore-keys: | - rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}- - - - name: Lint code for consistent style - run: bin/rubocop -f github - - test: - runs-on: ubuntu-latest - - services: - postgres: - image: postgres - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 - - # redis: - # image: valkey/valkey:8 - # ports: - # - 6379:6379 - # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 - - steps: - - name: Install packages - run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libpq-dev - - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true - - - name: Run tests - env: - RAILS_ENV: test - DATABASE_URL: postgres://postgres:postgres@localhost:5432 - # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} - # REDIS_URL: redis://localhost:6379/0 - run: bin/rails db:test:prepare test diff --git a/api/Gemfile b/api/Gemfile index 147244a..5b58aa3 100644 --- a/api/Gemfile +++ b/api/Gemfile @@ -39,3 +39,4 @@ gem "rack-cors" gem "dotenv-rails" gem "httparty" gem "resend", "~> 1.0" +gem "solid_queue" diff --git a/api/Gemfile.lock b/api/Gemfile.lock index d059807..940e28c 100644 --- a/api/Gemfile.lock +++ b/api/Gemfile.lock @@ -86,9 +86,9 @@ GEM bundler-audit (0.9.3) bundler (>= 1.2.0) thor (~> 1.0) - concurrent-ruby (1.3.6) + concurrent-ruby (1.3.8) connection_pool (3.0.2) - crass (1.0.6) + crass (1.0.7) csv (3.3.5) date (3.5.1) debug (1.11.1) @@ -101,6 +101,11 @@ GEM drb (2.2.3) erb (6.0.4) erubi (1.13.1) + et-orbi (1.4.0) + tzinfo + fugit (1.13.0) + et-orbi (~> 1.4) + raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) httparty (0.24.2) @@ -115,13 +120,13 @@ GEM prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.19.4) - jwt (3.1.2) + json (2.21.1) + jwt (3.2.0) base64 language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) - loofah (2.25.1) + loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.9.0) @@ -135,10 +140,10 @@ GEM minitest (6.0.5) drb (~> 2.0) prism (~> 1.5) - msgpack (1.8.0) + msgpack (1.8.3) multi_xml (0.8.1) bigdecimal (>= 3.1, < 5) - net-imap (0.6.4) + net-imap (0.6.6) date net-protocol net-pop (0.1.2) @@ -148,21 +153,21 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.19.3-aarch64-linux-gnu) + nokogiri (1.19.4-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.3-aarch64-linux-musl) + nokogiri (1.19.4-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.3-arm-linux-gnu) + nokogiri (1.19.4-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.3-arm-linux-musl) + nokogiri (1.19.4-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.3-arm64-darwin) + nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.3-x86_64-darwin) + nokogiri (1.19.4-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.3-x86_64-linux-gnu) + nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.3-x86_64-linux-musl) + nokogiri (1.19.4-x86_64-linux-musl) racc (~> 1.4) parallel (2.1.0) parser (3.3.11.1) @@ -182,8 +187,9 @@ GEM psych (5.3.1) date stringio - puma (8.0.1) + puma (8.0.2) nio4r (~> 2.0) + raabro (1.5.0) racc (1.8.1) rack (3.2.6) rack-cors (3.0.0) @@ -214,8 +220,8 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.7.0) - loofah (~> 2.25) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) railties (8.1.3) actionpack (= 8.1.3) @@ -268,6 +274,13 @@ GEM rubocop-rails (>= 2.30) ruby-progressbar (1.13.0) securerandom (0.4.1) + solid_queue (1.5.0) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) stringio (3.2.0) thor (1.5.0) timeout (0.6.1) @@ -279,7 +292,7 @@ GEM unicode-emoji (4.2.0) uri (1.1.1) useragent (0.16.11) - websocket-driver (0.8.0) + websocket-driver (0.8.2) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -311,6 +324,7 @@ DEPENDENCIES rails (~> 8.1.3) resend (~> 1.0) rubocop-rails-omakase + solid_queue tzinfo-data CHECKSUMS @@ -333,9 +347,9 @@ CHECKSUMS brakeman (8.0.4) sha256=7bf921fa9638544835df9aa7b3e720a9a72c0267f34f92135955edd80d4dcf6f builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 - concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab + concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a - crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d + crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 @@ -344,36 +358,38 @@ CHECKSUMS drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc + fugit (1.13.0) sha256=a4f093fce740da52f216740a5041e2a594ea763cdb89e8b2754ca4399634ab18 globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 httparty (0.24.2) sha256=8fca6a54aa0c4aa4303a0fd33e5e2156175d6a5334f714263b458abd7fda9c38 i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 - json (2.19.4) sha256=670a7d333fb3b18ca5b29cb255eb7bef099e40d88c02c80bd42a3f30fe5239ac - jwt (3.1.2) sha256=af6991f19a6bb4060d618d9add7a66f0eeb005ac0bc017cd01f63b42e122d535 + json (2.21.1) sha256=13a43df75d95641443f5702dff350f237164a9d811ff0f2c2800d4d980220583 + jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 - loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 + loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.5) sha256=f007d7246bf4feea549502842cd7c6aba8851cdc9c90ba06de9c476c0d01155c - msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 + msgpack (1.8.3) sha256=8bda4a6428d3244e50d6bd55854d354edbada88a4e1f4f5731a39a0f86bee6a1 multi_xml (0.8.1) sha256=addba0290bac34e9088bfe73dc4878530297a82a7bbd66cb44dcd0a4b86edf5a - net-imap (0.6.4) sha256=9a5598c67a3022c284d98430ef1d4948e7dbdb62596f61081ea8ca933270a02b + net-imap (0.6.6) sha256=96aa4ee50df3060203e649efc341f53480b791d49e150f2fdebf68beb141a8df net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 - nokogiri (1.19.3-aarch64-linux-gnu) sha256=46b89e5d7b9e844c2ee360794240c6ea2a4e6fa0c5892a4ed487db621224b639 - nokogiri (1.19.3-aarch64-linux-musl) sha256=8392dfdcd21be7a94dbbe9ccc138dea01b97b24cb2dc02a114ca98bfb1d9a0b7 - nokogiri (1.19.3-arm-linux-gnu) sha256=3919d5ffc334ad778a4a9eb88fda7dcb8b1fb58c8a52ac640c6dcd2f038e774f - nokogiri (1.19.3-arm-linux-musl) sha256=9ce1cb6346bb9c67b1550eb537aa183ead91e4b6eadb2f36ade02d8dd2a79fb6 - nokogiri (1.19.3-arm64-darwin) sha256=71b9bd424b1b7abc18b05052a1a3cfd3627abdca62be280854cc411791357e42 - nokogiri (1.19.3-x86_64-darwin) sha256=77f3fba57d46c53ab31e62fc6c28f705109d1bf6264356c76f132b2be5728d4d - nokogiri (1.19.3-x86_64-linux-gnu) sha256=2f5078620fe12e83669b5b17311b32532a8153d02eee7ad06948b926d6080976 - nokogiri (1.19.3-x86_64-linux-musl) sha256=248c906d2166eca5efb56d52fdee5f9a1f51d69a72e2b64fdac647b4ce39ea3f + nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f + nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af + nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca + nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb + nokogiri (1.19.4-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527 + nokogiri (1.19.4-x86_64-darwin) sha256=7fd17057d3e1f00e9954a74b3cd76595d3d4a5ef233b7ed9599047c204f70551 + nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a + nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99 @@ -387,7 +403,8 @@ CHECKSUMS prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 - puma (8.0.1) sha256=7b94e50c07655718c1fb8ae41a11fc06c7d61293208b3aa608ff71a46d3ad37c + puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb + raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 rack-cors (3.0.0) sha256=7b95be61db39606906b61b83bd7203fa802b0ceaaad8fcb2fef39e097bf53f68 @@ -396,7 +413,7 @@ CHECKSUMS rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 rails (8.1.3) sha256=6d017ba5348c98fc909753a8169b21d44de14d2a0b92d140d1a966834c3c9cd3 rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d - rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89 + rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2 railties (8.1.3) sha256=913eb0e0cb520aac687ffd74916bd726d48fa21f47833c6292576ef6a286de22 rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 @@ -411,6 +428,7 @@ CHECKSUMS rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + solid_queue (1.5.0) sha256=329f29caab9cc68eefe489013eb5a38ea4a66708ed2bee301384dbed91890f7f stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb @@ -420,7 +438,7 @@ CHECKSUMS unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 - websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 + websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd diff --git a/api/app/controllers/api/v1/organization_invitations_controller.rb b/api/app/controllers/api/v1/organization_invitations_controller.rb index 25018ab..52d595a 100644 --- a/api/app/controllers/api/v1/organization_invitations_controller.rb +++ b/api/app/controllers/api/v1/organization_invitations_controller.rb @@ -12,6 +12,9 @@ def show def accept invitation = OrganizationInvitation.pending.find_by!(token: params[:token]) + if invitation.organization.archived? + return render json: { errors: [ "This classroom is archived and read-only" ] }, status: :unprocessable_entity + end if invitation.email != current_user.email.downcase return render_forbidden("This invitation is for a different account.") end @@ -29,6 +32,12 @@ def accept_invitation!(invitation) ApplicationRecord.transaction do accept_membership!(invitation) invitation.update!(accepted_at: Time.current) + audit_event!( + "organization.invitation.accepted", + organization: invitation.organization, + target: invitation, + metadata: { role: invitation.role } + ) end end @@ -70,7 +79,6 @@ def organization_json(organization) def invitation_json(invitation) { token: invitation.token, - email: invitation.email, role: invitation.role, organization: { id: invitation.organization.id, diff --git a/api/app/controllers/api/v1/organizations_controller.rb b/api/app/controllers/api/v1/organizations_controller.rb index 79763f9..b67cf56 100644 --- a/api/app/controllers/api/v1/organizations_controller.rb +++ b/api/app/controllers/api/v1/organizations_controller.rb @@ -4,10 +4,16 @@ class OrganizationsController < ApplicationController before_action :authenticate_user! before_action :set_organization, only: [ :show, + :update, + :archive, + :unarchive, :members, :projects, + :export, + :audit_events, :student_projects, :invite, + :bulk_invite, :invitations, :resend_invitation, :destroy_invitation, @@ -26,6 +32,40 @@ def show render json: { organization: organization_json(@organization) } end + def update + return render_forbidden unless can_manage_org?(current_user, @organization) + return render_archived_organization_error if @organization.archived? + + before = @organization.slice("name", "school_year") + if @organization.update(params.permit(:name, :school_year)) + audit_event!( + "organization.updated", + organization: @organization, + target: @organization, + metadata: { before: before, after: @organization.slice("name", "school_year") } + ) + render json: { organization: organization_json(@organization.reload) } + else + render json: { errors: @organization.errors.full_messages }, status: :unprocessable_entity + end + end + + def archive + return render_forbidden unless can_manage_org?(current_user, @organization) + + @organization.update!(archived_at: Time.current) + audit_event!("organization.archived", organization: @organization, target: @organization) + render json: { organization: organization_json(@organization.reload) } + end + + def unarchive + return render_forbidden unless can_manage_org?(current_user, @organization) + + @organization.update!(archived_at: nil) + audit_event!("organization.restored", organization: @organization, target: @organization) + render json: { organization: organization_json(@organization.reload) } + end + def create return render_forbidden("Only platform admins and mentors can create organizations.") unless can_create_org?(current_user) @@ -35,6 +75,7 @@ def create ApplicationRecord.transaction do organization = current_user.created_organizations.create!(params.permit(:name)) organization.organization_memberships.create!(user: current_user, role: :owner) + audit_event!("organization.created", organization: organization, target: organization) end render json: { organization: organization_json(organization.reload) }, status: :created rescue ActiveRecord::RecordInvalid => e @@ -55,43 +96,144 @@ def members end def projects - projects = visible_organization_projects(@organization) - .includes(:project_files, :user, :organization) - .order(updated_at: :desc) - render json: { projects: projects.map { |project| project_json(project) } } + scope = visible_organization_projects(@organization).order(updated_at: :desc, id: :desc) + projects, pagination = paginate(scope.includes(:project_files, :user, :organization)) + render json: { + projects: projects.map { |project| project_json(project) }, + pagination: pagination + } + end + + def export + membership = organization_membership_for(current_user, @organization) + return render_forbidden unless current_user.admin? || membership + + can_export_class = current_user.admin? || membership&.instructor? || membership&.owner? + projects = @organization.projects + .includes(:project_files, :user, :organization, project_comments: [ :user, :resolved_by ]) + .order(:user_id, :created_at) + projects = projects.where(user: current_user) unless can_export_class + memberships = @organization.organization_memberships.includes(:user) + memberships = memberships.where(user: current_user) unless can_export_class + audit_event!( + can_export_class ? "organization.exported" : "organization.personal_work_exported", + organization: @organization, + target: @organization, + metadata: { project_count: projects.size, member_count: memberships.size } + ) + + render json: { + export: { + version: 1, + exported_at: Time.current, + organization: organization_json(@organization), + members: memberships.map { |candidate| member_json(candidate) }, + projects: projects.map { |project| project_export_json(project) } + } + } + end + + def audit_events + return render_forbidden unless can_manage_org?(current_user, @organization) + + events = @organization.audit_events.includes(:actor).order(created_at: :desc, id: :desc).limit(100) + render json: { + audit_events: events.map do |event| + { + id: event.id, + action: event.action, + actor: event.actor && { id: event.actor.id, full_name: event.actor.full_name }, + target_type: event.target_type, + target_id: event.target_id, + metadata: event.metadata, + created_at: event.created_at + } + end + } end def student_projects return render_forbidden unless can_view_org_roster?(current_user, @organization) student = @organization.members.find_by!(id: params[:student_id]) - projects = @organization.projects.includes(:project_files, :user, :organization) - .where(user: student) - .order(updated_at: :desc) - render json: { student: user_json(student), projects: projects.map { |project| project_json(project) } } + scope = @organization.projects.where(user: student).order(updated_at: :desc, id: :desc) + projects, pagination = paginate(scope.includes(:project_files, :user, :organization)) + render json: { + student: user_json(student), + projects: projects.map { |project| project_json(project) }, + pagination: pagination + } end def invite return render_forbidden unless can_invite_org_member?(current_user, @organization) + return render_archived_organization_error if @organization.archived? role = invitation_role_param return render json: { errors: [ "Role is not valid" ] }, status: :unprocessable_entity unless role return render_forbidden("Only organization owners can invite instructors.") if role == "instructor" && !can_manage_org?(current_user, @organization) return render_invitation_url_configuration_error unless frontend_origin - invitation = @organization.organization_invitations.new( - invited_by: current_user, - email: params[:email], - role: role - ) - - if invitation.save + normalized_email = params[:email].to_s.strip.downcase + return render_email_domain_error unless invitation_email_domain_allowed?(normalized_email) + begin + invitation = create_or_renew_invitation(normalized_email, role) invitation_url = organization_invitation_url(invitation) - email_sent = OrganizationInviteEmailService.send_invite(invitation: invitation, invitation_url: invitation_url) + email_queued = enqueue_invitation_email(invitation, invitation_url) + audit_event!( + "organization.invitation.created", + organization: @organization, + target: invitation, + metadata: { role: invitation.role, email_queued: email_queued } + ) + + render json: { invitation: invitation_json(invitation, invitation_url: invitation_url, email_queued: email_queued) }, status: :created + rescue ActiveRecord::RecordInvalid => e + render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity + end + end - render json: { invitation: invitation_json(invitation, invitation_url: invitation_url, email_sent: email_sent) }, status: :created - else - render json: { errors: invitation.errors.full_messages }, status: :unprocessable_entity + def bulk_invite + return render_forbidden unless can_invite_org_member?(current_user, @organization) + return render_archived_organization_error if @organization.archived? + role = invitation_role_param + return render json: { errors: [ "Role is not valid" ] }, status: :unprocessable_entity unless role + return render_forbidden("Only organization owners can invite instructors.") if role == "instructor" && !can_manage_org?(current_user, @organization) + return render_invitation_url_configuration_error unless frontend_origin + + emails = Array(params[:emails]).flat_map { |value| value.to_s.split(/[\s,;]+/) } + .map { |email| email.strip.downcase } + .reject(&:blank?) + .uniq + return render json: { errors: [ "Enter at least one email address" ] }, status: :unprocessable_entity if emails.empty? + return render json: { errors: [ "Invite up to 100 people at a time" ] }, status: :unprocessable_entity if emails.length > 100 + + invitations = [] + errors = [] + emails.each do |email| + unless invitation_email_domain_allowed?(email) + errors << { email: email, errors: [ "Email domain is not allowed for this workspace" ] } + next + end + + begin + invitation = create_or_renew_invitation(email, role) + rescue ActiveRecord::RecordInvalid => e + errors << { email: email, errors: e.record.errors.full_messages } + next + end + + invitation_url = organization_invitation_url(invitation) + email_queued = enqueue_invitation_email(invitation, invitation_url) + audit_event!( + "organization.invitation.created", + organization: @organization, + target: invitation, + metadata: { role: invitation.role, email_queued: email_queued, bulk: true } + ) + invitations << invitation_json(invitation, invitation_url: invitation_url, email_queued: email_queued) end + + render json: { invitations: invitations, errors: errors }, status: errors.any? ? :multi_status : :created end def invitations @@ -103,25 +245,37 @@ def invitations def resend_invitation return render_forbidden unless can_invite_org_member?(current_user, @organization) + return render_archived_organization_error if @organization.archived? return render_forbidden("Only organization owners can resend instructor invitations.") unless can_manage_invitation_role?(@invitation.role) return render json: { errors: [ "Invitation is no longer pending" ] }, status: :unprocessable_entity unless invitation_pending?(@invitation) return render_invitation_url_configuration_error unless frontend_origin invitation_url = organization_invitation_url(@invitation) - email_sent = OrganizationInviteEmailService.send_invite(invitation: @invitation, invitation_url: invitation_url) - render json: { invitation: invitation_json(@invitation, invitation_url: invitation_url, email_sent: email_sent) } + email_queued = enqueue_invitation_email(@invitation, invitation_url) + audit_event!( + "organization.invitation.resent", + organization: @organization, + target: @invitation, + metadata: { role: @invitation.role, email_queued: email_queued } + ) + render json: { invitation: invitation_json(@invitation, invitation_url: invitation_url, email_queued: email_queued) } end def destroy_invitation return render_forbidden unless can_invite_org_member?(current_user, @organization) + return render_archived_organization_error if @organization.archived? return render_forbidden("Only organization owners can revoke instructor invitations.") unless can_manage_invitation_role?(@invitation.role) - @invitation.destroy! + ApplicationRecord.transaction do + audit_event!("organization.invitation.revoked", organization: @organization, target: @invitation, metadata: { role: @invitation.role }) + @invitation.destroy! + end head :no_content end def update_member return render_forbidden unless can_manage_org?(current_user, @organization) + return render_archived_organization_error if @organization.archived? return render json: { errors: [ "You cannot change your own organization role." ] }, status: :unprocessable_entity if @membership.user_id == current_user.id role = membership_role_param @@ -135,7 +289,14 @@ def update_member raise ActiveRecord::Rollback end + previous_role = @membership.role @membership.update!(role: role) + audit_event!( + "organization.member.role_changed", + organization: @organization, + target: @membership.user, + metadata: { from: previous_role, to: role } + ) end return render json: { errors: [ owner_guard_error ] }, status: :unprocessable_entity if owner_guard_error @@ -144,6 +305,7 @@ def update_member def destroy_member return render_forbidden unless can_manage_org?(current_user, @organization) + return render_archived_organization_error if @organization.archived? return render json: { errors: [ "You cannot remove yourself from the organization." ] }, status: :unprocessable_entity if @membership.user_id == current_user.id owner_guard_error = nil @@ -154,6 +316,17 @@ def destroy_member raise ActiveRecord::Rollback end + @membership.user.projects.where(organization: @membership.organization).update_all( + organization_id: nil, + visibility: "private", + updated_at: Time.current + ) + audit_event!( + "organization.member.removed", + organization: @organization, + target: @membership.user, + metadata: { previous_role: @membership.role } + ) @membership.destroy! end return render json: { errors: [ owner_guard_error ] }, status: :unprocessable_entity if owner_guard_error @@ -185,7 +358,9 @@ def organization_json(organization) id: organization.id, name: organization.name, slug: organization.slug, - role: membership&.role, + role: membership&.role || (current_user.admin? ? "admin" : nil), + school_year: organization.school_year, + archived_at: organization.archived_at, created_at: organization.created_at, updated_at: organization.updated_at } @@ -210,20 +385,103 @@ def user_json(user) } end - def invitation_json(invitation, invitation_url: nil, email_sent: nil) + def invitation_json(invitation, invitation_url: nil, email_queued: nil) { id: invitation.id, email: invitation.email, role: invitation.role, token: invitation.token, invitation_url: invitation_url, - email_sent: email_sent, + email_queued: email_queued, + delivery_status: invitation.delivery_status, + delivery_error: invitation.delivery_error, + last_sent_at: invitation.last_sent_at, + send_attempts: invitation.send_attempts, accepted_at: invitation.accepted_at, expires_at: invitation.expires_at, created_at: invitation.created_at }.compact end + def project_export_json(project) + project_json(project).merge( + comments: project.project_comments.map do |comment| + { + id: comment.id, + body: comment.body, + file_path: comment.file_path, + line_number: comment.line_number, + author: comment.user.full_name, + resolved_at: comment.resolved_at, + resolved_by: comment.resolved_by&.full_name, + created_at: comment.created_at, + updated_at: comment.updated_at + } + end + ) + end + + def enqueue_invitation_email(invitation, invitation_url) + return false unless OrganizationInviteEmailService.configured? + + invitation.update!(delivery_status: "queued", delivery_error: nil) + OrganizationInviteEmailJob.perform_later(invitation.id, invitation_url) + true + rescue => e + invitation.update_columns( + delivery_status: "failed", + delivery_error: e.message.to_s.first(500), + updated_at: Time.current + ) + Rails.logger.error("[OrganizationsController] could not enqueue invitation #{invitation.id}: #{e.class} #{e.message}") + false + end + + def create_or_renew_invitation(email, role) + retries = 0 + begin + OrganizationInvitation.transaction(requires_new: true) do + invitation = @organization.organization_invitations + .where(email: email, accepted_at: nil) + .lock + .first + + if invitation + invitation.renew!(invited_by: current_user, role: role) + invitation + else + @organization.organization_invitations.create!( + invited_by: current_user, + email: email, + role: role + ) + end + end + rescue ActiveRecord::RecordNotUnique + retries += 1 + retry if retries < 3 + + raise + end + end + + def invitation_email_domain_allowed?(email) + domains = ENV.fetch("ALLOWED_MEMBER_EMAIL_DOMAINS", "").split(",") + .map { |domain| domain.strip.downcase.delete_prefix("@") } + .reject(&:blank?) + return true if domains.empty? + + domains.include?(email.to_s.split("@", 2).last.to_s.downcase) + end + + def render_email_domain_error + render json: { errors: [ "Email domain is not allowed for this workspace" ] }, status: :unprocessable_entity + end + + def render_archived_organization_error + render json: { errors: [ "This classroom is archived and read-only" ] }, status: :unprocessable_entity + end + def visible_organization_projects(organization) membership = organization_membership_for(current_user, organization) return organization.projects if current_user.admin? || membership&.instructor? || membership&.owner? @@ -280,17 +538,33 @@ def organization_invitation_url(invitation, require_configured_origin: true) end def frontend_origin(log_missing: true) - origin = ENV["FRONTEND_URL"].presence || ENV["APP_URL"].presence - return origin.delete_suffix("/") if origin - return "http://localhost:5173" unless Rails.env.production? - - Rails.logger.error("[OrganizationsController] FRONTEND_URL or APP_URL must be set before sending organization invitation links") if log_missing - nil + Rails.application.config.x.public_app_origin end def render_invitation_url_configuration_error render json: { errors: [ "Invitation links are not configured. Set FRONTEND_URL or APP_URL." ] }, status: :unprocessable_entity end + + def paginate(scope) + page = positive_integer_param(:page, 1) + per_page = [ positive_integer_param(:per_page, 50), 100 ].min + total_count = scope.count + records = scope.offset((page - 1) * per_page).limit(per_page) + [ + records, + { + page: page, + per_page: per_page, + total_count: total_count, + total_pages: (total_count.to_f / per_page).ceil + } + ] + end + + def positive_integer_param(name, default) + value = Integer(params[name], exception: false) + value&.positive? ? value : default + end end end end diff --git a/api/app/controllers/api/v1/project_checkpoints_controller.rb b/api/app/controllers/api/v1/project_checkpoints_controller.rb index 5c7c96f..94fe352 100644 --- a/api/app/controllers/api/v1/project_checkpoints_controller.rb +++ b/api/app/controllers/api/v1/project_checkpoints_controller.rb @@ -7,6 +7,7 @@ class ProjectCheckpointsController < ApplicationController before_action :authenticate_user! before_action :set_project + before_action :ensure_classroom_writable!, only: [ :create, :restore ] before_action :set_checkpoint, only: [ :restore ] def index @@ -53,6 +54,7 @@ def restore position: file["position"] || index ) end + @project.updated_at = Time.current @project.save! end @@ -71,6 +73,12 @@ def set_checkpoint @checkpoint = @project.project_checkpoints.find(params[:id]) end + def ensure_classroom_writable! + return unless @project.organization&.archived? + + render json: { errors: [ "This classroom is archived and read-only" ] }, status: :unprocessable_entity + end + def prune_old_checkpoints(saved_checkpoint) checkpoint_ids = @project.project_checkpoints .where.not(id: saved_checkpoint.id) diff --git a/api/app/controllers/api/v1/project_comments_controller.rb b/api/app/controllers/api/v1/project_comments_controller.rb new file mode 100644 index 0000000..8bf59b0 --- /dev/null +++ b/api/app/controllers/api/v1/project_comments_controller.rb @@ -0,0 +1,117 @@ +module Api + module V1 + class ProjectCommentsController < ApplicationController + before_action :authenticate_user! + before_action :set_project + before_action :authorize_feedback! + before_action :set_comment, only: [ :resolve ] + + def index + comments = @project.project_comments.includes(:user, :resolved_by).order(:created_at, :id) + unread_count = comments.count do |comment| + comment.user_id != current_user.id && comment.created_at > last_read_at + end + + render json: { + comments: comments.map { |comment| comment_json(comment) }, + unread_count: unread_count + } + end + + def create + comment = @project.project_comments.new(comment_params.merge(user: current_user)) + + if comment.save + mark_read! + audit_event!( + "project.feedback.created", + organization: @project.organization, + target: comment, + metadata: { project_id: @project.id, file_path: comment.file_path, line_number: comment.line_number } + ) + render json: { comment: comment_json(comment) }, status: :created + else + render json: { errors: comment.errors.full_messages }, status: :unprocessable_entity + end + end + + def resolve + resolved = ActiveModel::Type::Boolean.new.cast(params.fetch(:resolved, true)) + @comment.update!( + resolved_at: resolved ? Time.current : nil, + resolved_by: resolved ? current_user : nil + ) + audit_event!( + resolved ? "project.feedback.resolved" : "project.feedback.reopened", + organization: @project.organization, + target: @comment, + metadata: { project_id: @project.id } + ) + + render json: { comment: comment_json(@comment.reload) } + end + + def mark_read + mark_read! + head :no_content + end + + private + + def set_project + @project = Project.includes(:project_files, :organization, :user).find(params[:project_id]) + end + + def authorize_feedback! + render_forbidden("Feedback is private to the project owner and teaching staff.") unless can_access_project_feedback?(current_user, @project) + end + + def set_comment + @comment = @project.project_comments.find(params[:id]) + end + + def comment_params + params.permit(:body, :file_path, :line_number) + end + + def mark_read! + read = @project.project_comment_reads.find_or_initialize_by(user: current_user) + read.update!(read_at: Time.current) + end + + def last_read_at + @last_read_at ||= @project.project_comment_reads.find_by(user: current_user)&.read_at || Time.at(0) + end + + def comment_json(comment) + { + id: comment.id, + body: comment.body, + file_path: comment.file_path, + line_number: comment.line_number, + resolved_at: comment.resolved_at, + edited_at: comment.edited_at, + created_at: comment.created_at, + updated_at: comment.updated_at, + author: { + id: comment.user.id, + full_name: comment.user.full_name, + role: comment_author_role(comment.user) + }, + resolved_by: comment.resolved_by && { + id: comment.resolved_by.id, + full_name: comment.resolved_by.full_name + } + } + end + + def comment_author_role(user) + return "admin" if user.admin? + return "owner" if @project.organization && organization_membership_for(user, @project.organization)&.owner? + return "instructor" if @project.organization && organization_membership_for(user, @project.organization)&.instructor? + + "student" + end + end + end +end diff --git a/api/app/controllers/api/v1/project_shares_controller.rb b/api/app/controllers/api/v1/project_shares_controller.rb index c351ca6..d02c760 100644 --- a/api/app/controllers/api/v1/project_shares_controller.rb +++ b/api/app/controllers/api/v1/project_shares_controller.rb @@ -3,9 +3,12 @@ module V1 class ProjectSharesController < ApplicationController MAX_FILES = Project::MAX_FILES MAX_FILE_BYTES = 500_000 + MAX_TOTAL_BYTES = Project::MAX_TOTAL_CONTENT_BYTES SHARE_TTL = 30.days def create + organization = classroom_share_organization + return if performed? return render_rate_limited if share_rate_limited? snapshot = normalized_snapshot @@ -20,6 +23,12 @@ def create ) if share.save + audit_event!( + "project.share_created", + organization: organization, + target: share, + metadata: { kind: share.kind } + ) if organization render json: { share: share_json(share) }, status: :created else render json: { errors: share.errors.full_messages }, status: :unprocessable_entity @@ -45,12 +54,25 @@ def show private - def share_rate_limited? - key = "share-create:#{request.remote_ip}" - Rails.cache.write(key, 0, expires_in: 1.hour, unless_exist: true, raw: true) - count = Rails.cache.increment(key).to_i + def classroom_share_organization + return nil if params[:organization_id].blank? + + authenticate_user! + return nil if performed? + + organization = current_user.admin? ? + Organization.find(params[:organization_id]) : + current_user.organizations.find(params[:organization_id]) + unless ActiveModel::Type::Boolean.new.cast(ENV["ALLOW_ORGANIZATION_EXTERNAL_SHARING"]) + render json: { errors: [ "External snapshot sharing is disabled for classroom projects" ] }, status: :unprocessable_entity + return nil + end - count > 60 + organization + end + + def share_rate_limited? + ApiRateLimit.exceeded?("share-create:#{request.remote_ip}", limit: 60, period: 1.hour) end def render_rate_limited @@ -92,6 +114,7 @@ def normalized_snapshot end raise ArgumentError, "At least one valid file is required" if normalized_files.empty? + raise ArgumentError, "Project source is too large" if normalized_files.sum { |file| file.fetch(:content).bytesize } > MAX_TOTAL_BYTES raise ArgumentError, "File paths must be unique" if normalized_files.map { |file| file.fetch(:path) }.uniq.length != normalized_files.length entry_path = normalized_files.first.fetch(:path) if entry_path.blank? raise ArgumentError, "Entry path must match a project file" unless normalized_files.any? { |file| file.fetch(:path) == entry_path } diff --git a/api/app/controllers/api/v1/projects_controller.rb b/api/app/controllers/api/v1/projects_controller.rb index c0d16b6..38c030d 100644 --- a/api/app/controllers/api/v1/projects_controller.rb +++ b/api/app/controllers/api/v1/projects_controller.rb @@ -6,8 +6,20 @@ class ProjectsController < ApplicationController before_action :set_owned_project, only: [ :update, :destroy, :archive, :unarchive ] def index - projects = scoped_projects.includes(:project_files, :user, :organization).order(updated_at: :desc) - render json: { projects: projects.map { |project| project_json(project) } } + scope = scoped_projects.order(updated_at: :desc, id: :desc) + page = positive_integer_param(:page, 1) + per_page = [ positive_integer_param(:per_page, 50), 100 ].min + total_count = scope.count + projects = scope.includes(:project_files, :user, :organization).offset((page - 1) * per_page).limit(per_page) + render json: { + projects: projects.map { |project| project_json(project) }, + pagination: { + page: page, + per_page: per_page, + total_count: total_count, + total_pages: (total_count.to_f / per_page).ceil + } + } end def show @@ -17,9 +29,11 @@ def show def create project = current_user.projects.new(project_attrs) project.organization = project_organization + return render_archived_organization_error if project.organization&.archived? assign_files(project) if project.save + audit_event!("project.created", organization: project.organization, target: project, metadata: { visibility: project.visibility }) render json: { project: project_json(project) }, status: :created else render json: { errors: validation_errors(project) }, status: :unprocessable_entity @@ -27,36 +41,66 @@ def create end def update + return render_archived_organization_error if @project.organization&.archived? + + previous_visibility = @project.visibility Project.transaction do @project.assign_attributes(project_attrs) if params.key?(:files) @project.project_files.destroy_all assign_files(@project) + @project.updated_at = Time.current end @project.save! + if @project.visibility != previous_visibility + audit_event!( + "project.visibility_changed", + organization: @project.organization, + target: @project, + metadata: { from: previous_visibility, to: @project.visibility } + ) + end end render json: { project: project_json(@project.reload) } rescue ActiveRecord::RecordInvalid => e render json: { errors: validation_errors(e.record) }, status: :unprocessable_entity + rescue ActiveRecord::StaleObjectError + current = Project.includes(:project_files, :user, :organization).find(@project.id) + render json: { + error: "This project changed in another tab. Reload the latest version before saving again.", + code: "project_conflict", + project: project_json(current) + }, status: :conflict end def destroy - @project.destroy + return render_archived_organization_error if @project.organization&.archived? + + Project.transaction do + audit_event!("project.deleted", organization: @project.organization, target: @project, metadata: { visibility: @project.visibility }) + @project.destroy! + end head :no_content end def archive + return render_archived_organization_error if @project.organization&.archived? + @project.update!(archived_at: Time.current) render json: { project: project_json(@project.reload) } end def unarchive + return render_archived_organization_error if @project.organization&.archived? + @project.update!(archived_at: nil) render json: { project: project_json(@project.reload) } end def duplicate + return render_archived_organization_error if @project.organization&.archived? + copy = current_user.projects.new( title: "#{@project.title} Copy", kind: @project.kind, @@ -75,6 +119,12 @@ def duplicate end if copy.save + audit_event!( + "project.duplicated", + organization: copy.organization, + target: copy, + metadata: { source_project_id: @project.id } + ) render json: { project: project_json(copy) }, status: :created else render json: { errors: copy.errors.full_messages }, status: :unprocessable_entity @@ -103,6 +153,9 @@ def scoped_projects def set_accessible_project @project = Project.includes(:project_files, :user, :organization).find(params[:id]) render_forbidden unless can_view_project?(current_user, @project) + if current_user.admin? && @project.user_id != current_user.id && @project.visibility == "private" + audit_event!("project.private_viewed_by_admin", organization: @project.organization, target: @project) + end end def set_owned_project @@ -110,7 +163,7 @@ def set_owned_project end def project_attrs - params.permit(:title, :kind, :visibility, :entry_path) + params.permit(:title, :kind, :visibility, :entry_path, :lock_version) end def project_organization @@ -156,6 +209,15 @@ def validation_errors(project) (project.errors.full_messages + file_errors).uniq end + + def render_archived_organization_error + render json: { errors: [ "This classroom is archived and read-only" ] }, status: :unprocessable_entity + end + + def positive_integer_param(name, default) + value = Integer(params[name], exception: false) + value&.positive? ? value : default + end end end end diff --git a/api/app/controllers/application_controller.rb b/api/app/controllers/application_controller.rb index fcfe681..d560307 100644 --- a/api/app/controllers/application_controller.rb +++ b/api/app/controllers/application_controller.rb @@ -29,6 +29,7 @@ def project_json(project) slug: project.organization.slug }, archived_at: project.archived_at, + lock_version: project.lock_version, created_at: project.created_at, updated_at: project.updated_at, files: project.project_files.map do |file| @@ -42,4 +43,14 @@ def project_json(project) end } end + + def audit_event!(action, organization: nil, target: nil, metadata: {}) + AuditEvent.create!( + actor: current_user, + organization: organization, + target: target, + action: action, + metadata: metadata + ) + end end diff --git a/api/app/controllers/concerns/authorizable.rb b/api/app/controllers/concerns/authorizable.rb index 091ec9d..fb632e9 100644 --- a/api/app/controllers/concerns/authorizable.rb +++ b/api/app/controllers/concerns/authorizable.rb @@ -37,6 +37,14 @@ def can_edit_project?(user, project) project.user_id == user&.id end + def can_access_project_feedback?(user, project) + return true if user&.admin? + return true if project.user_id == user&.id + return false unless project.organization + + organization_instructor?(user, project.organization) + end + def can_manage_org?(user, organization) user&.admin? || organization_membership_for(user, organization)&.owner? end diff --git a/api/app/controllers/concerns/clerk_authenticatable.rb b/api/app/controllers/concerns/clerk_authenticatable.rb index 9bdfa3d..437ad9a 100644 --- a/api/app/controllers/concerns/clerk_authenticatable.rb +++ b/api/app/controllers/concerns/clerk_authenticatable.rb @@ -66,7 +66,8 @@ def find_or_create_user(clerk_id:, email:, first_name:, last_name:) end end - return nil if Rails.env.production? && !allow_open_signup? + invited_to_organization = email.present? && OrganizationInvitation.pending.exists?(email: email.downcase) + return nil if Rails.env.production? && !allow_open_signup? && !invited_to_organization User.create( clerk_id: clerk_id, diff --git a/api/app/jobs/cleanup_expired_data_job.rb b/api/app/jobs/cleanup_expired_data_job.rb new file mode 100644 index 0000000..65e49f1 --- /dev/null +++ b/api/app/jobs/cleanup_expired_data_job.rb @@ -0,0 +1,10 @@ +class CleanupExpiredDataJob < ApplicationJob + queue_as :maintenance + + def perform + now = Time.current + ProjectShare.where(expires_at: ...now).delete_all + OrganizationInvitation.where(accepted_at: nil, expires_at: ...now).delete_all + ApiRateLimit.where(window_started_at: ...1.day.ago).delete_all + end +end diff --git a/api/app/jobs/organization_invite_email_job.rb b/api/app/jobs/organization_invite_email_job.rb new file mode 100644 index 0000000..ad8dc6a --- /dev/null +++ b/api/app/jobs/organization_invite_email_job.rb @@ -0,0 +1,43 @@ +class OrganizationInviteEmailJob < ApplicationJob + queue_as :mailers + + discard_on ActiveRecord::RecordNotFound + retry_on StandardError, wait: :polynomially_longer, attempts: 5 + + def perform(invitation_id, invitation_url) + invitation = OrganizationInvitation.find(invitation_id) + response = OrganizationInviteEmailService.send_invite!( + invitation: invitation, + invitation_url: invitation_url + ) + + invitation.with_lock do + invitation.update!( + delivery_status: "sent", + delivery_error: nil, + last_sent_at: Time.current, + send_attempts: invitation.send_attempts + 1, + provider_message_id: provider_message_id(response) + ) + end + rescue => e + invitation&.with_lock do + invitation.update_columns( + delivery_status: "failed", + delivery_error: e.message.to_s.first(500), + send_attempts: invitation.send_attempts + 1, + updated_at: Time.current + ) + end + raise + end + + private + + def provider_message_id(response) + return response["id"] if response.respond_to?(:[]) && response["id"].present? + return response.id if response.respond_to?(:id) + + nil + end +end diff --git a/api/app/models/api_rate_limit.rb b/api/app/models/api_rate_limit.rb new file mode 100644 index 0000000..076971d --- /dev/null +++ b/api/app/models/api_rate_limit.rb @@ -0,0 +1,28 @@ +class ApiRateLimit < ApplicationRecord + validates :key, presence: true, uniqueness: true + validates :request_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + + def self.exceeded?(key, limit:, period:) + attempts = 0 + begin + transaction(requires_new: true) do + now = Time.current + record = lock.find_by(key: key) + record ||= create!(key: key, window_started_at: now, request_count: 0) + + if record.window_started_at <= period.ago + record.update!(window_started_at: now, request_count: 1) + false + else + record.increment!(:request_count) + record.request_count > limit + end + end + rescue ActiveRecord::RecordNotUnique + attempts += 1 + retry if attempts < 3 + + true + end + end +end diff --git a/api/app/models/audit_event.rb b/api/app/models/audit_event.rb new file mode 100644 index 0000000..d7108a8 --- /dev/null +++ b/api/app/models/audit_event.rb @@ -0,0 +1,17 @@ +class AuditEvent < ApplicationRecord + belongs_to :actor, class_name: "User", optional: true + belongs_to :organization, optional: true + belongs_to :target, polymorphic: true, optional: true + + validates :action, presence: true, length: { maximum: 120 } + validate :metadata_does_not_include_source + + private + + def metadata_does_not_include_source + forbidden_keys = %w[content source files snapshot] + return unless metadata.to_h.keys.map(&:to_s).intersect?(forbidden_keys) + + errors.add(:metadata, "cannot include project source") + end +end diff --git a/api/app/models/organization.rb b/api/app/models/organization.rb index f54ac88..77207ac 100644 --- a/api/app/models/organization.rb +++ b/api/app/models/organization.rb @@ -8,14 +8,20 @@ class Organization < ApplicationRecord has_many :members, through: :organization_memberships, source: :user has_many :organization_invitations, dependent: :destroy has_many :projects, dependent: :nullify + has_many :audit_events, dependent: :nullify before_destroy :privatize_organization_visible_projects, prepend: true validates :name, presence: true, length: { maximum: 120 } + validates :school_year, length: { maximum: 40 }, allow_blank: true validates :slug, presence: true, uniqueness: true, length: { maximum: 80 }, format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ } before_validation :set_slug + def archived? + archived_at.present? + end + private def privatize_organization_visible_projects diff --git a/api/app/models/organization_invitation.rb b/api/app/models/organization_invitation.rb index a4e30f1..70da1b1 100644 --- a/api/app/models/organization_invitation.rb +++ b/api/app/models/organization_invitation.rb @@ -10,6 +10,12 @@ class OrganizationInvitation < ApplicationRecord format: { with: URI::MailTo::EMAIL_REGEXP, message: "is invalid" } validates :role, presence: true validates :token, presence: true, uniqueness: true + validates :email, uniqueness: { + scope: :organization_id, + conditions: -> { where(accepted_at: nil) }, + message: "already has a pending invitation for this organization" + } + validates :delivery_status, inclusion: { in: %w[pending queued sent failed] } before_validation :normalize_email before_validation :ensure_token @@ -25,6 +31,21 @@ def expired? expires_at.present? && expires_at <= Time.current end + def renew!(invited_by:, role:) + assign_attributes( + invited_by: invited_by, + role: role, + token: nil, + expires_at: 14.days.from_now, + delivery_status: "pending", + delivery_error: nil, + provider_message_id: nil, + last_sent_at: nil, + send_attempts: 0 + ) + save! + end + private def normalize_email diff --git a/api/app/models/project.rb b/api/app/models/project.rb index 07a6b54..865e953 100644 --- a/api/app/models/project.rb +++ b/api/app/models/project.rb @@ -2,12 +2,15 @@ class Project < ApplicationRecord KINDS = %w[ruby javascript web].freeze VISIBILITIES = %w[private organization unlisted public].freeze MAX_FILES = 50 + MAX_TOTAL_CONTENT_BYTES = 2_000_000 belongs_to :user belongs_to :organization, optional: true belongs_to :forked_from, class_name: "Project", optional: true has_many :project_files, -> { order(:position, :id) }, dependent: :destroy, inverse_of: :project has_many :project_checkpoints, -> { order(created_at: :desc) }, dependent: :destroy + has_many :project_comments, dependent: :destroy + has_many :project_comment_reads, dependent: :destroy validates :title, presence: true, length: { maximum: 120 } validates :kind, inclusion: { in: KINDS } @@ -16,8 +19,10 @@ class Project < ApplicationRecord validates_associated :project_files validate :file_count_within_limit validate :has_at_least_one_file + validate :total_content_within_limit validate :entry_path_matches_file validate :organization_visibility_requires_organization + validate :organization_external_sharing_requires_policy before_validation :set_default_entry_path @@ -60,6 +65,13 @@ def has_at_least_one_file errors.add(:project_files, "must include at least one file") end + def total_content_within_limit + total_bytes = project_files.reject(&:marked_for_destruction?).sum { |file| file.content.to_s.bytesize } + return if total_bytes <= MAX_TOTAL_CONTENT_BYTES + + errors.add(:project_files, "cannot contain more than #{MAX_TOTAL_CONTENT_BYTES / 1_000_000} MB of source code") + end + def entry_path_matches_file return if entry_path.blank? return if project_files.reject(&:marked_for_destruction?).any? { |file| file.path == entry_path } @@ -73,4 +85,12 @@ def organization_visibility_requires_organization errors.add(:organization, "must be present for organization visibility") end + + def organization_external_sharing_requires_policy + return unless organization_id.present? && visibility.in?(%w[unlisted public]) + return unless new_record? || will_save_change_to_visibility? + return if ActiveModel::Type::Boolean.new.cast(ENV["ALLOW_ORGANIZATION_EXTERNAL_SHARING"]) + + errors.add(:visibility, "must stay teacher-only or class-visible for this organization") + end end diff --git a/api/app/models/project_checkpoint.rb b/api/app/models/project_checkpoint.rb index 05e1dae..440e65f 100644 --- a/api/app/models/project_checkpoint.rb +++ b/api/app/models/project_checkpoint.rb @@ -3,4 +3,13 @@ class ProjectCheckpoint < ApplicationRecord validates :title, presence: true, length: { maximum: 120 } validates :snapshot, presence: true + validate :snapshot_within_limit + + private + + def snapshot_within_limit + return if snapshot.to_json.bytesize <= Project::MAX_TOTAL_CONTENT_BYTES + 100_000 + + errors.add(:snapshot, "is too large") + end end diff --git a/api/app/models/project_comment.rb b/api/app/models/project_comment.rb new file mode 100644 index 0000000..51a9271 --- /dev/null +++ b/api/app/models/project_comment.rb @@ -0,0 +1,32 @@ +class ProjectComment < ApplicationRecord + MAX_BODY_LENGTH = 10_000 + + belongs_to :project + belongs_to :user + belongs_to :resolved_by, class_name: "User", optional: true + + validates :body, presence: true, length: { maximum: MAX_BODY_LENGTH } + validates :file_path, length: { maximum: 160 }, allow_blank: true + validates :line_number, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true + validate :file_path_belongs_to_project + validate :line_number_requires_file_path + + def resolved? + resolved_at.present? + end + + private + + def file_path_belongs_to_project + return if file_path.blank? || !project + return if project.project_files.any? { |file| file.path == file_path } + + errors.add(:file_path, "must match a project file") + end + + def line_number_requires_file_path + return if line_number.blank? || file_path.present? + + errors.add(:line_number, "requires a file path") + end +end diff --git a/api/app/models/project_comment_read.rb b/api/app/models/project_comment_read.rb new file mode 100644 index 0000000..04c2e75 --- /dev/null +++ b/api/app/models/project_comment_read.rb @@ -0,0 +1,7 @@ +class ProjectCommentRead < ApplicationRecord + belongs_to :project + belongs_to :user + + validates :read_at, presence: true + validates :user_id, uniqueness: { scope: :project_id } +end diff --git a/api/app/models/project_file.rb b/api/app/models/project_file.rb index ab0c7f7..3ccc653 100644 --- a/api/app/models/project_file.rb +++ b/api/app/models/project_file.rb @@ -9,6 +9,7 @@ class ProjectFile < ApplicationRecord validates :content, length: { maximum: 500_000 } validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validate :path_is_safe_relative_path + validate :content_bytes_within_limit private @@ -35,4 +36,10 @@ def path_is_safe_relative_path errors.add(:path, "cannot include hidden files or folders yet") end end + + def content_bytes_within_limit + return if content.to_s.bytesize <= 500_000 + + errors.add(:content, "is too large (maximum is 500 KB)") + end end diff --git a/api/app/models/project_share.rb b/api/app/models/project_share.rb index 53fbb89..d7b0433 100644 --- a/api/app/models/project_share.rb +++ b/api/app/models/project_share.rb @@ -6,6 +6,7 @@ class ProjectShare < ApplicationRecord validates :title, presence: true, length: { maximum: 120 } validates :kind, inclusion: { in: KINDS } validates :snapshot, presence: true + validate :snapshot_within_limit before_validation :ensure_token @@ -19,4 +20,10 @@ def ensure_token break candidate unless self.class.exists?(token: candidate) end end + + def snapshot_within_limit + return if snapshot.to_json.bytesize <= Project::MAX_TOTAL_CONTENT_BYTES + 100_000 + + errors.add(:snapshot, "is too large") + end end diff --git a/api/app/models/user.rb b/api/app/models/user.rb index 9fdbb40..322ba1e 100644 --- a/api/app/models/user.rb +++ b/api/app/models/user.rb @@ -5,6 +5,9 @@ class User < ApplicationRecord has_many :organization_memberships, dependent: :destroy has_many :organizations, through: :organization_memberships has_many :created_organizations, class_name: "Organization", foreign_key: :created_by_id, dependent: :restrict_with_error + has_many :project_comments, dependent: :restrict_with_error + has_many :project_comment_reads, dependent: :destroy + has_many :audit_events, foreign_key: :actor_id, dependent: :nullify validates :clerk_id, presence: true, uniqueness: true validates :email, presence: true, uniqueness: { case_sensitive: false } diff --git a/api/app/services/organization_invite_email_service.rb b/api/app/services/organization_invite_email_service.rb index 48cfb7c..54e9329 100644 --- a/api/app/services/organization_invite_email_service.rb +++ b/api/app/services/organization_invite_email_service.rb @@ -2,10 +2,19 @@ class OrganizationInviteEmailService BRAND_NAME = "Hafa Code" + class ConfigurationError < StandardError; end class << self def send_invite(invitation:, invitation_url:) - return false unless configured? + send_invite!(invitation: invitation, invitation_url: invitation_url) + true + rescue StandardError => e + Rails.logger.error("[OrgInviteEmail] failed for #{invitation.email}: #{e.class} #{e.message}") + false + end + + def send_invite!(invitation:, invitation_url:) + raise ConfigurationError, "Invitation email is not configured" unless configured? response = Resend::Emails.send( { @@ -18,10 +27,7 @@ def send_invite(invitation:, invitation_url:) ) Rails.logger.info("[OrgInviteEmail] sent invite to #{invitation.email} response=#{response.inspect}") - true - rescue StandardError => e - Rails.logger.error("[OrgInviteEmail] failed for #{invitation.email}: #{e.class} #{e.message}") - false + response end def configured? diff --git a/api/bin/jobs b/api/bin/jobs new file mode 100755 index 0000000..dcf59f3 --- /dev/null +++ b/api/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/api/config/environments/production.rb b/api/config/environments/production.rb index f1303ec..af72548 100644 --- a/api/config/environments/production.rb +++ b/api/config/environments/production.rb @@ -43,8 +43,8 @@ # Replace the default in-process memory cache store with a durable alternative. # config.cache_store = :mem_cache_store - # Replace the default in-process and non-durable queuing backend for Active Job. - # config.active_job.queue_adapter = :resque + # Persist invitation delivery and other background work in PostgreSQL. + config.active_job.queue_adapter = :solid_queue # Ignore bad email addresses and do not raise email delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors. diff --git a/api/config/initializers/app_origin.rb b/api/config/initializers/app_origin.rb new file mode 100644 index 0000000..ab5d772 --- /dev/null +++ b/api/config/initializers/app_origin.rb @@ -0,0 +1,4 @@ +configured_origin = ENV["FRONTEND_URL"].presence || ENV["APP_URL"].presence +default_origin = Rails.env.production? ? "https://hafa-code.netlify.app" : "http://localhost:5173" + +Rails.application.config.x.public_app_origin = (configured_origin || default_origin).delete_suffix("/") diff --git a/api/config/initializers/cors.rb b/api/config/initializers/cors.rb index 17b31af..9dff844 100644 --- a/api/config/initializers/cors.rb +++ b/api/config/initializers/cors.rb @@ -1,7 +1,8 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do - allowed = ENV.fetch("ALLOWED_ORIGINS", ENV.fetch("FRONTEND_URL", "http://localhost:5173")) - origins allowed.split(",").map(&:strip) + configured_origins = ENV.fetch("ALLOWED_ORIGINS", "").split(",").map(&:strip).reject(&:blank?) + allowed_origins = (configured_origins + [ Rails.application.config.x.public_app_origin ]).uniq + origins allowed_origins resource "*", headers: :any, diff --git a/api/config/puma.rb b/api/config/puma.rb index 1c317b4..80f53f4 100644 --- a/api/config/puma.rb +++ b/api/config/puma.rb @@ -34,6 +34,9 @@ # Allow puma to be restarted by `bin/rails restart` command. plugin :tmp_restart +# Run the queue supervisor with the web process on the single Render service. +plugin :solid_queue if %w[1 true yes].include?(ENV["SOLID_QUEUE_IN_PUMA"].to_s.downcase) + # Specify the PID file. Defaults to tmp/pids/server.pid in development. # In other environments, only set the PID file if requested. pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/api/config/queue.yml b/api/config/queue.yml new file mode 100644 index 0000000..6b14360 --- /dev/null +++ b/api/config/queue.yml @@ -0,0 +1,18 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 3 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/api/config/recurring.yml b/api/config/recurring.yml new file mode 100644 index 0000000..3fa8547 --- /dev/null +++ b/api/config/recurring.yml @@ -0,0 +1,8 @@ +production: + clear_solid_queue_finished_jobs: + command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" + schedule: every hour at minute 12 + cleanup_expired_classroom_data: + class: CleanupExpiredDataJob + queue: maintenance + schedule: every day at 3am Pacific/Guam diff --git a/api/config/routes.rb b/api/config/routes.rb index 96309bd..10c03b9 100644 --- a/api/config/routes.rb +++ b/api/config/routes.rb @@ -6,6 +6,15 @@ namespace :v1 do post "sessions", to: "sessions#create" resources :projects do + resources :comments, controller: "project_comments", only: [ :index, :create ] do + collection do + post :mark_read + end + member do + patch :resolve + end + end + resources :checkpoints, controller: "project_checkpoints", only: [ :index, :create ] do member do post :restore @@ -19,13 +28,18 @@ end end resources :shares, controller: "project_shares", param: :token, only: [ :create, :show ] - resources :organizations, only: [ :index, :show, :create ] do + resources :organizations, only: [ :index, :show, :create, :update ] do member do + patch :archive + patch :unarchive get :members get :projects + get :export, to: "organizations#export" + get :audit_events get "students/:student_id/projects", to: "organizations#student_projects" get :invitations post :invite + post :bulk_invite post "invitations/:invitation_id/resend", to: "organizations#resend_invitation" delete "invitations/:invitation_id", to: "organizations#destroy_invitation" patch "members/:membership_id", to: "organizations#update_member" diff --git a/api/db/migrate/20260725000001_add_classroom_feedback_and_project_locking.rb b/api/db/migrate/20260725000001_add_classroom_feedback_and_project_locking.rb new file mode 100644 index 0000000..444a9c6 --- /dev/null +++ b/api/db/migrate/20260725000001_add_classroom_feedback_and_project_locking.rb @@ -0,0 +1,29 @@ +class AddClassroomFeedbackAndProjectLocking < ActiveRecord::Migration[8.1] + def change + add_column :projects, :lock_version, :integer, null: false, default: 0 + + create_table :project_comments do |t| + t.references :project, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.text :body, null: false + t.string :file_path + t.integer :line_number + t.datetime :edited_at + t.datetime :resolved_at + t.references :resolved_by, foreign_key: { to_table: :users } + + t.timestamps + end + add_index :project_comments, [ :project_id, :created_at ] + add_index :project_comments, [ :project_id, :resolved_at ] + + create_table :project_comment_reads do |t| + t.references :project, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.datetime :read_at, null: false + + t.timestamps + end + add_index :project_comment_reads, [ :project_id, :user_id ], unique: true + end +end diff --git a/api/db/migrate/20260725000002_harden_organization_invitations.rb b/api/db/migrate/20260725000002_harden_organization_invitations.rb new file mode 100644 index 0000000..4707b35 --- /dev/null +++ b/api/db/migrate/20260725000002_harden_organization_invitations.rb @@ -0,0 +1,49 @@ +class HardenOrganizationInvitations < ActiveRecord::Migration[8.1] + def up + add_column :organization_invitations, :delivery_status, :string, null: false, default: "pending" + add_column :organization_invitations, :last_sent_at, :datetime + add_column :organization_invitations, :send_attempts, :integer, null: false, default: 0 + add_column :organization_invitations, :delivery_error, :string + add_column :organization_invitations, :provider_message_id, :string + + execute <<~SQL.squish + UPDATE organization_invitations + SET email = LOWER(TRIM(email)) + SQL + + execute <<~SQL.squish + DELETE FROM organization_invitations + WHERE id IN ( + SELECT older.id + FROM organization_invitations older + INNER JOIN organization_invitations newer + ON newer.organization_id = older.organization_id + AND newer.email = older.email + AND newer.accepted_at IS NULL + AND older.accepted_at IS NULL + AND newer.id > older.id + ) + SQL + + remove_index :organization_invitations, name: "index_org_invitations_on_org_email_accepted" + add_index :organization_invitations, + [ :organization_id, :email ], + unique: true, + where: "accepted_at IS NULL", + name: "index_org_invitations_on_pending_email" + end + + def down + remove_index :organization_invitations, name: "index_org_invitations_on_pending_email" + add_index :organization_invitations, + [ :organization_id, :email, :accepted_at ], + unique: true, + name: "index_org_invitations_on_org_email_accepted" + + remove_column :organization_invitations, :provider_message_id + remove_column :organization_invitations, :delivery_error + remove_column :organization_invitations, :send_attempts + remove_column :organization_invitations, :last_sent_at + remove_column :organization_invitations, :delivery_status + end +end diff --git a/api/db/migrate/20260725000003_create_solid_queue_tables.rb b/api/db/migrate/20260725000003_create_solid_queue_tables.rb new file mode 100644 index 0000000..7e5825b --- /dev/null +++ b/api/db/migrate/20260725000003_create_solid_queue_tables.rb @@ -0,0 +1,142 @@ +class CreateSolidQueueTables < ActiveRecord::Migration[8.1] + def change + create_table :solid_queue_blocked_executions do |t| + t.bigint :job_id, null: false + t.string :queue_name, null: false + t.integer :priority, default: 0, null: false + t.string :concurrency_key, null: false + t.datetime :expires_at, null: false + t.datetime :created_at, null: false + + t.index [ :concurrency_key, :priority, :job_id ], name: "index_solid_queue_blocked_executions_for_release" + t.index [ :expires_at, :concurrency_key ], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index :job_id, unique: true + end + + create_table :solid_queue_claimed_executions do |t| + t.bigint :job_id, null: false + t.bigint :process_id + t.datetime :created_at, null: false + + t.index :job_id, unique: true + t.index [ :process_id, :job_id ] + end + + create_table :solid_queue_failed_executions do |t| + t.bigint :job_id, null: false + t.text :error + t.datetime :created_at, null: false + + t.index :job_id, unique: true + end + + create_table :solid_queue_jobs do |t| + t.string :queue_name, null: false + t.string :class_name, null: false + t.text :arguments + t.integer :priority, default: 0, null: false + t.string :active_job_id + t.datetime :scheduled_at + t.datetime :finished_at + t.string :concurrency_key + t.datetime :created_at, null: false + t.datetime :updated_at, null: false + + t.index :active_job_id + t.index :class_name + t.index :finished_at + t.index [ :queue_name, :finished_at ], name: "index_solid_queue_jobs_for_filtering" + t.index [ :scheduled_at, :finished_at ], name: "index_solid_queue_jobs_for_alerting" + end + + create_table :solid_queue_pauses do |t| + t.string :queue_name, null: false + t.datetime :created_at, null: false + + t.index :queue_name, unique: true + end + + create_table :solid_queue_processes do |t| + t.string :kind, null: false + t.datetime :last_heartbeat_at, null: false + t.bigint :supervisor_id + t.integer :pid, null: false + t.string :hostname + t.text :metadata + t.datetime :created_at, null: false + t.string :name, null: false + + t.index :last_heartbeat_at + t.index [ :name, :supervisor_id ], unique: true + t.index :supervisor_id + end + + create_table :solid_queue_ready_executions do |t| + t.bigint :job_id, null: false + t.string :queue_name, null: false + t.integer :priority, default: 0, null: false + t.datetime :created_at, null: false + + t.index :job_id, unique: true + t.index [ :priority, :job_id ], name: "index_solid_queue_poll_all" + t.index [ :queue_name, :priority, :job_id ], name: "index_solid_queue_poll_by_queue" + end + + create_table :solid_queue_recurring_executions do |t| + t.bigint :job_id, null: false + t.string :task_key, null: false + t.datetime :run_at, null: false + t.datetime :created_at, null: false + + t.index :job_id, unique: true + t.index [ :task_key, :run_at ], unique: true + end + + create_table :solid_queue_recurring_tasks do |t| + t.string :key, null: false + t.string :schedule, null: false + t.string :command, limit: 2_048 + t.string :class_name + t.text :arguments + t.string :queue_name + t.integer :priority, default: 0 + t.boolean :static, default: true, null: false + t.text :description + t.datetime :created_at, null: false + t.datetime :updated_at, null: false + + t.index :key, unique: true + t.index :static + end + + create_table :solid_queue_scheduled_executions do |t| + t.bigint :job_id, null: false + t.string :queue_name, null: false + t.integer :priority, default: 0, null: false + t.datetime :scheduled_at, null: false + t.datetime :created_at, null: false + + t.index :job_id, unique: true + t.index [ :scheduled_at, :priority, :job_id ], name: "index_solid_queue_dispatch_all" + end + + create_table :solid_queue_semaphores do |t| + t.string :key, null: false + t.integer :value, default: 1, null: false + t.datetime :expires_at, null: false + t.datetime :created_at, null: false + t.datetime :updated_at, null: false + + t.index :expires_at + t.index [ :key, :value ] + t.index :key, unique: true + end + + add_foreign_key :solid_queue_blocked_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade + add_foreign_key :solid_queue_claimed_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade + add_foreign_key :solid_queue_failed_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade + add_foreign_key :solid_queue_ready_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade + add_foreign_key :solid_queue_recurring_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade + add_foreign_key :solid_queue_scheduled_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade + end +end diff --git a/api/db/migrate/20260725000004_add_classroom_lifecycle_to_organizations.rb b/api/db/migrate/20260725000004_add_classroom_lifecycle_to_organizations.rb new file mode 100644 index 0000000..59fc223 --- /dev/null +++ b/api/db/migrate/20260725000004_add_classroom_lifecycle_to_organizations.rb @@ -0,0 +1,7 @@ +class AddClassroomLifecycleToOrganizations < ActiveRecord::Migration[8.1] + def change + add_column :organizations, :school_year, :string + add_column :organizations, :archived_at, :datetime + add_index :organizations, :archived_at + end +end diff --git a/api/db/migrate/20260725000005_create_audit_events.rb b/api/db/migrate/20260725000005_create_audit_events.rb new file mode 100644 index 0000000..24528a1 --- /dev/null +++ b/api/db/migrate/20260725000005_create_audit_events.rb @@ -0,0 +1,15 @@ +class CreateAuditEvents < ActiveRecord::Migration[8.1] + def change + create_table :audit_events do |t| + t.references :actor, foreign_key: { to_table: :users } + t.references :organization, foreign_key: true + t.references :target, polymorphic: true + t.string :action, null: false + t.jsonb :metadata, null: false, default: {} + t.timestamps + end + + add_index :audit_events, [ :organization_id, :created_at ] + add_index :audit_events, [ :action, :created_at ] + end +end diff --git a/api/db/migrate/20260725000006_create_api_rate_limits.rb b/api/db/migrate/20260725000006_create_api_rate_limits.rb new file mode 100644 index 0000000..2c861db --- /dev/null +++ b/api/db/migrate/20260725000006_create_api_rate_limits.rb @@ -0,0 +1,13 @@ +class CreateApiRateLimits < ActiveRecord::Migration[8.1] + def change + create_table :api_rate_limits do |t| + t.string :key, null: false + t.datetime :window_started_at, null: false + t.integer :request_count, null: false, default: 0 + t.timestamps + end + + add_index :api_rate_limits, :key, unique: true + add_index :api_rate_limits, :window_started_at + end +end diff --git a/api/db/schema.rb b/api/db/schema.rb index b676cb5..a79251d 100644 --- a/api/db/schema.rb +++ b/api/db/schema.rb @@ -10,22 +10,53 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_05_12_000400) do +ActiveRecord::Schema[8.1].define(version: 2026_07_25_000006) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + create_table "api_rate_limits", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "key", null: false + t.integer "request_count", default: 0, null: false + t.datetime "updated_at", null: false + t.datetime "window_started_at", null: false + t.index ["key"], name: "index_api_rate_limits_on_key", unique: true + t.index ["window_started_at"], name: "index_api_rate_limits_on_window_started_at" + end + + create_table "audit_events", force: :cascade do |t| + t.string "action", null: false + t.bigint "actor_id" + t.datetime "created_at", null: false + t.jsonb "metadata", default: {}, null: false + t.bigint "organization_id" + t.bigint "target_id" + t.string "target_type" + t.datetime "updated_at", null: false + t.index ["action", "created_at"], name: "index_audit_events_on_action_and_created_at" + t.index ["actor_id"], name: "index_audit_events_on_actor_id" + t.index ["organization_id", "created_at"], name: "index_audit_events_on_organization_id_and_created_at" + t.index ["organization_id"], name: "index_audit_events_on_organization_id" + t.index ["target_type", "target_id"], name: "index_audit_events_on_target" + end + create_table "organization_invitations", force: :cascade do |t| t.datetime "accepted_at" t.datetime "created_at", null: false + t.string "delivery_error" + t.string "delivery_status", default: "pending", null: false t.string "email", null: false t.datetime "expires_at" t.bigint "invited_by_id", null: false + t.datetime "last_sent_at" t.bigint "organization_id", null: false + t.string "provider_message_id" t.integer "role", default: 0, null: false + t.integer "send_attempts", default: 0, null: false t.string "token", null: false t.datetime "updated_at", null: false t.index ["invited_by_id"], name: "index_organization_invitations_on_invited_by_id" - t.index ["organization_id", "email", "accepted_at"], name: "index_org_invitations_on_org_email_accepted" + t.index ["organization_id", "email"], name: "index_org_invitations_on_pending_email", unique: true, where: "(accepted_at IS NULL)" t.index ["organization_id"], name: "index_organization_invitations_on_organization_id" t.index ["token"], name: "index_organization_invitations_on_token", unique: true end @@ -43,11 +74,14 @@ end create_table "organizations", force: :cascade do |t| + t.datetime "archived_at" t.datetime "created_at", null: false t.bigint "created_by_id", null: false t.string "name", null: false + t.string "school_year" t.string "slug", null: false t.datetime "updated_at", null: false + t.index ["archived_at"], name: "index_organizations_on_archived_at" t.index ["created_by_id"], name: "index_organizations_on_created_by_id" t.index ["slug"], name: "index_organizations_on_slug", unique: true end @@ -62,6 +96,35 @@ t.index ["project_id"], name: "index_project_checkpoints_on_project_id" end + create_table "project_comment_reads", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "project_id", null: false + t.datetime "read_at", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["project_id", "user_id"], name: "index_project_comment_reads_on_project_id_and_user_id", unique: true + t.index ["project_id"], name: "index_project_comment_reads_on_project_id" + t.index ["user_id"], name: "index_project_comment_reads_on_user_id" + end + + create_table "project_comments", force: :cascade do |t| + t.text "body", null: false + t.datetime "created_at", null: false + t.datetime "edited_at" + t.string "file_path" + t.integer "line_number" + t.bigint "project_id", null: false + t.datetime "resolved_at" + t.bigint "resolved_by_id" + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["project_id", "created_at"], name: "index_project_comments_on_project_id_and_created_at" + t.index ["project_id", "resolved_at"], name: "index_project_comments_on_project_id_and_resolved_at" + t.index ["project_id"], name: "index_project_comments_on_project_id" + t.index ["resolved_by_id"], name: "index_project_comments_on_resolved_by_id" + t.index ["user_id"], name: "index_project_comments_on_user_id" + end + create_table "project_files", force: :cascade do |t| t.text "content", default: "", null: false t.datetime "created_at", null: false @@ -93,6 +156,7 @@ t.string "entry_path" t.bigint "forked_from_id" t.string "kind", null: false + t.integer "lock_version", default: 0, null: false t.bigint "organization_id" t.string "title", null: false t.datetime "updated_at", null: false @@ -108,6 +172,127 @@ t.index ["visibility"], name: "index_projects_on_visibility" end + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.string "concurrency_key", null: false + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" + t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.bigint "process_id" + t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "error" + t.bigint "job_id", null: false + t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "active_job_id" + t.text "arguments" + t.string "class_name", null: false + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "finished_at" + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at" + t.datetime "updated_at", null: false + t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" + t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" + t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at" + t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering" + t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "queue_name", null: false + t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "hostname" + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.text "metadata" + t.string "name", null: false + t.integer "pid", null: false + t.bigint "supervisor_id" + t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" + t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.datetime "run_at", null: false + t.string "task_key", null: false + t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.text "arguments" + t.string "class_name" + t.string "command", limit: 2048 + t.datetime "created_at", null: false + t.text "description" + t.string "key", null: false + t.integer "priority", default: 0 + t.string "queue_name" + t.string "schedule", null: false + t.boolean "static", default: true, null: false + t.datetime "updated_at", null: false + t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at", null: false + t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "key", null: false + t.datetime "updated_at", null: false + t.integer "value", default: 1, null: false + t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" + t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" + t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true + end + create_table "users", force: :cascade do |t| t.string "clerk_id", null: false t.datetime "created_at", null: false @@ -121,14 +306,27 @@ t.index ["clerk_id"], name: "index_users_on_clerk_id", unique: true end + add_foreign_key "audit_events", "organizations" + add_foreign_key "audit_events", "users", column: "actor_id" add_foreign_key "organization_invitations", "organizations" add_foreign_key "organization_invitations", "users", column: "invited_by_id" add_foreign_key "organization_memberships", "organizations" add_foreign_key "organization_memberships", "users" add_foreign_key "organizations", "users", column: "created_by_id" add_foreign_key "project_checkpoints", "projects" + add_foreign_key "project_comment_reads", "projects" + add_foreign_key "project_comment_reads", "users" + add_foreign_key "project_comments", "projects" + add_foreign_key "project_comments", "users" + add_foreign_key "project_comments", "users", column: "resolved_by_id" add_foreign_key "project_files", "projects" add_foreign_key "projects", "organizations" add_foreign_key "projects", "projects", column: "forked_from_id" add_foreign_key "projects", "users" + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade end diff --git a/api/test/integration/classroom_feedback_api_test.rb b/api/test/integration/classroom_feedback_api_test.rb new file mode 100644 index 0000000..8a2f585 --- /dev/null +++ b/api/test/integration/classroom_feedback_api_test.rb @@ -0,0 +1,110 @@ +require "test_helper" + +class ClassroomFeedbackApiTest < ActionDispatch::IntegrationTest + setup do + @teacher = create_user("teacher", "teacher@example.com") + @student = create_user("student", "student@example.com") + @classmate = create_user("classmate", "classmate@example.com") + @organization = Organization.create!(name: "FDMS Period 1", created_by: @teacher) + @organization.organization_memberships.create!(user: @teacher, role: :owner) + @organization.organization_memberships.create!(user: @student, role: :student) + @organization.organization_memberships.create!(user: @classmate, role: :student) + @project = @student.projects.create!( + organization: @organization, + title: "Private Ruby", + kind: "ruby", + visibility: "private", + project_files: [ + ProjectFile.new(path: "main.rb", language: "ruby", content: "puts 'hafa'") + ] + ) + end + + test "teacher and project owner can discuss private work" do + post "/api/v1/projects/#{@project.id}/comments", + params: { body: "Explain why this loop stops.", file_path: "main.rb", line_number: 1 }.to_json, + headers: headers_for(@teacher) + + assert_response :created + comment_id = response.parsed_body.dig("comment", "id") + assert_equal "owner", response.parsed_body.dig("comment", "author", "role") + assert_equal "main.rb", response.parsed_body.dig("comment", "file_path") + + get "/api/v1/projects/#{@project.id}/comments", headers: headers_for(@student) + + assert_response :success + assert_equal 1, response.parsed_body.fetch("unread_count") + assert_equal "Explain why this loop stops.", response.parsed_body.dig("comments", 0, "body") + + post "/api/v1/projects/#{@project.id}/comments", + params: { body: "It stops after the collection is exhausted." }.to_json, + headers: headers_for(@student) + + assert_response :created + + patch "/api/v1/projects/#{@project.id}/comments/#{comment_id}/resolve", + params: { resolved: true }.to_json, + headers: headers_for(@student) + + assert_response :success + assert_not_nil response.parsed_body.dig("comment", "resolved_at") + assert_equal @student.id, response.parsed_body.dig("comment", "resolved_by", "id") + end + + test "classmates cannot read feedback even when they belong to the organization" do + @project.project_comments.create!(user: @teacher, body: "Private teacher note") + + get "/api/v1/projects/#{@project.id}/comments", headers: headers_for(@classmate) + + assert_response :forbidden + assert_equal "Feedback is private to the project owner and teaching staff.", response.parsed_body.fetch("error") + end + + test "marking feedback read resets unread count" do + @project.project_comments.create!(user: @teacher, body: "Please add one test.") + + get "/api/v1/projects/#{@project.id}/comments", headers: headers_for(@student) + assert_equal 1, response.parsed_body.fetch("unread_count") + + post "/api/v1/projects/#{@project.id}/comments/mark_read", headers: headers_for(@student) + assert_response :no_content + + get "/api/v1/projects/#{@project.id}/comments", headers: headers_for(@student) + assert_response :success + assert_equal 0, response.parsed_body.fetch("unread_count") + end + + test "feedback file and line references must be valid" do + post "/api/v1/projects/#{@project.id}/comments", + params: { body: "Look here", file_path: "missing.rb", line_number: 4 }.to_json, + headers: headers_for(@teacher) + + assert_response :unprocessable_entity + assert_includes response.parsed_body.fetch("errors"), "File path must match a project file" + + post "/api/v1/projects/#{@project.id}/comments", + params: { body: "Look here", line_number: 4 }.to_json, + headers: headers_for(@teacher) + + assert_response :unprocessable_entity + assert_includes response.parsed_body.fetch("errors"), "Line number requires a file path" + end + + private + + def create_user(key, email) + User.create!( + clerk_id: "test_clerk_#{key}", + email: email, + first_name: key.capitalize, + last_name: "User" + ) + end + + def headers_for(user) + { + "Authorization" => "Bearer test_token_#{user.id}", + "Content-Type" => "application/json" + } + end +end diff --git a/api/test/integration/projects_api_test.rb b/api/test/integration/projects_api_test.rb index 854835d..c1107b0 100644 --- a/api/test/integration/projects_api_test.rb +++ b/api/test/integration/projects_api_test.rb @@ -115,6 +115,35 @@ class ProjectsApiTest < ActionDispatch::IntegrationTest assert_equal "private", project.reload.visibility end + test "class projects cannot be published outside the class unless policy enables it" do + organization = Organization.create!(name: "Private Classroom", created_by: @user) + organization.organization_memberships.create!(user: @user, role: :student) + + post "/api/v1/projects", + params: { + title: "Class Project", + kind: "ruby", + visibility: "public", + organization_id: organization.id, + files: [ { path: "main.rb", language: "ruby", content: "puts 'classroom'" } ] + }.to_json, + headers: @headers + + assert_response :unprocessable_entity + assert_includes response.parsed_body.fetch("errors"), "Visibility must stay teacher-only or class-visible for this organization" + + post "/api/v1/projects", + params: { + title: "Personal Public Project", + kind: "ruby", + visibility: "public", + files: [ { path: "main.rb", language: "ruby", content: "puts 'personal'" } ] + }.to_json, + headers: @headers + + assert_response :created + end + test "rejects unsafe file paths and entry paths" do post "/api/v1/projects", params: { @@ -173,6 +202,65 @@ class ProjectsApiTest < ActionDispatch::IntegrationTest assert_equal [ "main.rb" ], project.project_files.pluck(:path) end + test "rejects stale project updates instead of silently overwriting newer work" do + project = @user.projects.create!( + title: "Ruby Playground", + kind: "ruby", + project_files: [ ProjectFile.new(path: "main.rb", language: "ruby", content: "puts 'original'") ] + ) + original_lock_version = project.lock_version + + project.update!(title: "Changed in another tab") + + patch "/api/v1/projects/#{project.id}", + params: { + title: "Stale title", + kind: "ruby", + lock_version: original_lock_version, + files: [ { path: "main.rb", language: "ruby", content: "puts 'stale'" } ] + }.to_json, + headers: @headers + + assert_response :conflict + assert_equal "project_conflict", response.parsed_body.fetch("code") + assert_equal "Changed in another tab", response.parsed_body.dig("project", "title") + assert_equal "puts 'original'", project.reload.project_files.first.content + end + + test "file-only saves increment the lock and reject a stale second tab" do + project = @user.projects.create!( + title: "Ruby Playground", + kind: "ruby", + project_files: [ ProjectFile.new(path: "main.rb", language: "ruby", content: "puts 'original'") ] + ) + original_lock_version = project.lock_version + + patch "/api/v1/projects/#{project.id}", + params: { + title: project.title, + kind: project.kind, + lock_version: original_lock_version, + files: [ { path: "main.rb", language: "ruby", content: "puts 'first tab'" } ] + }.to_json, + headers: @headers + + assert_response :success + assert_equal original_lock_version + 1, response.parsed_body.dig("project", "lock_version") + + patch "/api/v1/projects/#{project.id}", + params: { + title: project.title, + kind: project.kind, + lock_version: original_lock_version, + files: [ { path: "main.rb", language: "ruby", content: "puts 'stale tab'" } ] + }.to_json, + headers: @headers + + assert_response :conflict + assert_equal "project_conflict", response.parsed_body.fetch("code") + assert_equal "puts 'first tab'", project.reload.project_files.first.content + end + test "archives and restores projects" do project = @user.projects.create!( title: "Ruby Playground", @@ -361,6 +449,41 @@ class ProjectsApiTest < ActionDispatch::IntegrationTest assert_equal 3, response.parsed_body.dig("share", "snapshot", "files").length end + test "classroom snapshot sharing is disabled by default and audited when explicitly enabled" do + organization = Organization.create!(name: "Sharing School", created_by: @user) + organization.organization_memberships.create!(user: @user, role: :owner) + payload = { + title: "Classroom Ruby", + kind: "ruby", + organization_id: organization.id, + files: [ { path: "main.rb", language: "ruby", content: "puts 'classroom'" } ] + } + old_external_sharing = ENV["ALLOW_ORGANIZATION_EXTERNAL_SHARING"] + ENV.delete("ALLOW_ORGANIZATION_EXTERNAL_SHARING") + + post "/api/v1/shares", params: payload.to_json, headers: @headers + + assert_response :unprocessable_entity + assert_equal [ "External snapshot sharing is disabled for classroom projects" ], response.parsed_body.fetch("errors") + assert_equal 0, ProjectShare.count + + ENV["ALLOW_ORGANIZATION_EXTERNAL_SHARING"] = "true" + post "/api/v1/shares", params: payload.to_json, headers: @headers + + assert_response :created + event = AuditEvent.order(:id).last + assert_equal "project.share_created", event.action + assert_equal organization, event.organization + assert_equal @user, event.actor + assert_not event.metadata.key?("files") + ensure + if old_external_sharing + ENV["ALLOW_ORGANIZATION_EXTERNAL_SHARING"] = old_external_sharing + else + ENV.delete("ALLOW_ORGANIZATION_EXTERNAL_SHARING") + end + end + test "does not serve expired share snapshots" do share = ProjectShare.create!( title: "Expired", @@ -581,6 +704,11 @@ class ProjectsApiTest < ActionDispatch::IntegrationTest role: :student ) + get "/api/v1/invitations/#{invitation.token}" + + assert_response :success + assert_no_match "private-invitee@example.com", response.body + post "/api/v1/invitations/#{invitation.token}/accept", headers: @headers assert_response :forbidden @@ -774,24 +902,42 @@ class ProjectsApiTest < ActionDispatch::IntegrationTest assert_response :unprocessable_entity assert_includes response.parsed_body.fetch("errors"), "Email is invalid" - original_send_invite = OrganizationInviteEmailService.method(:send_invite) - OrganizationInviteEmailService.define_singleton_method(:send_invite) { |**| false } + original_configured = OrganizationInviteEmailService.method(:configured?) + OrganizationInviteEmailService.define_singleton_method(:configured?) { false } begin post "/api/v1/organizations/#{organization.id}/invite", params: { email: "student@example.com", role: "student" }.to_json, headers: owner_headers ensure - OrganizationInviteEmailService.define_singleton_method(:send_invite, original_send_invite) + OrganizationInviteEmailService.define_singleton_method(:configured?, original_configured) end assert_response :created invitation = response.parsed_body.fetch("invitation") assert_equal "student@example.com", invitation.fetch("email") assert_equal "student", invitation.fetch("role") - assert_equal false, invitation.fetch("email_sent") + assert_equal false, invitation.fetch("email_queued") + assert_equal "pending", invitation.fetch("delivery_status") assert_match "#invite=", invitation.fetch("invitation_url") + original_token = invitation.fetch("token") + original_configured = OrganizationInviteEmailService.method(:configured?) + OrganizationInviteEmailService.define_singleton_method(:configured?) { false } + + begin + post "/api/v1/organizations/#{organization.id}/invite", + params: { email: " STUDENT@example.com ", role: "student" }.to_json, + headers: owner_headers + ensure + OrganizationInviteEmailService.define_singleton_method(:configured?, original_configured) + end + + assert_response :created + renewed_invitation = response.parsed_body.fetch("invitation") + assert_equal 1, organization.organization_invitations.where(email: "student@example.com", accepted_at: nil).count + assert_not_equal original_token, renewed_invitation.fetch("token") + instructor = User.create!( clerk_id: "test_clerk_invite_instructor", email: "org-instructor@example.com", @@ -853,18 +999,19 @@ class ProjectsApiTest < ActionDispatch::IntegrationTest assert_not_includes instructor_visible_invitations.map { |candidate| candidate.fetch("email") }, "teacher@example.com" student_invitation_id = invitations.find { |candidate| candidate.fetch("email") == "student@example.com" }.fetch("id") - original_send_invite = OrganizationInviteEmailService.method(:send_invite) - OrganizationInviteEmailService.define_singleton_method(:send_invite) { |**| false } + original_configured = OrganizationInviteEmailService.method(:configured?) + OrganizationInviteEmailService.define_singleton_method(:configured?) { false } begin post "/api/v1/organizations/#{organization.id}/invitations/#{student_invitation_id}/resend", headers: owner_headers ensure - OrganizationInviteEmailService.define_singleton_method(:send_invite, original_send_invite) + OrganizationInviteEmailService.define_singleton_method(:configured?, original_configured) end assert_response :success - assert_equal false, response.parsed_body.dig("invitation", "email_sent") + assert_equal false, response.parsed_body.dig("invitation", "email_queued") + assert_equal "pending", response.parsed_body.dig("invitation", "delivery_status") assert_match "#invite=", response.parsed_body.dig("invitation", "invitation_url") revoked_token = response.parsed_body.dig("invitation", "token") @@ -878,19 +1025,190 @@ class ProjectsApiTest < ActionDispatch::IntegrationTest assert_response :not_found end - test "production invitation origin does not silently fall back to localhost" do + test "production invitation origin uses the declared Netlify app instead of localhost" do old_frontend_url = ENV.delete("FRONTEND_URL") old_app_url = ENV.delete("APP_URL") old_rails_env = Rails.method(:env) + old_public_app_origin = Rails.application.config.x.public_app_origin Rails.define_singleton_method(:env) { ActiveSupport::StringInquirer.new("production") } + load Rails.root.join("config/initializers/app_origin.rb") - assert_nil Api::V1::OrganizationsController.new.send(:frontend_origin) + assert_equal "https://hafa-code.netlify.app", Api::V1::OrganizationsController.new.send(:frontend_origin) ensure Rails.define_singleton_method(:env, old_rails_env) if old_rails_env + Rails.application.config.x.public_app_origin = old_public_app_origin ENV["FRONTEND_URL"] = old_frontend_url if old_frontend_url ENV["APP_URL"] = old_app_url if old_app_url end + test "organization owners can paste a roster and enforce allowed email domains" do + owner = User.create!( + clerk_id: "test_clerk_bulk_invite_owner", + email: "bulk-owner@fdms.org", + first_name: "Bulk", + last_name: "Owner", + role: :mentor + ) + organization = Organization.create!(name: "Bulk Invite School", created_by: owner) + organization.organization_memberships.create!(user: owner, role: :owner) + owner_headers = { + "Authorization" => "Bearer test_token_#{owner.id}", + "Content-Type" => "application/json" + } + old_allowed_domains = ENV["ALLOWED_MEMBER_EMAIL_DOMAINS"] + ENV["ALLOWED_MEMBER_EMAIL_DOMAINS"] = "fdms.org" + original_configured = OrganizationInviteEmailService.method(:configured?) + OrganizationInviteEmailService.define_singleton_method(:configured?) { false } + + post "/api/v1/organizations/#{organization.id}/bulk_invite", + params: { + emails: [ "student1@fdms.org", "STUDENT2@FDMS.ORG", "outsider@example.com", "student1@fdms.org" ], + role: "student" + }.to_json, + headers: owner_headers + + assert_response :multi_status + assert_equal [ "student1@fdms.org", "student2@fdms.org" ], response.parsed_body.fetch("invitations").pluck("email") + assert_equal [ "outsider@example.com" ], response.parsed_body.fetch("errors").pluck("email") + assert_equal 2, organization.organization_invitations.count + + post "/api/v1/organizations/#{organization.id}/invite", + params: { email: "outsider@example.com", role: "student" }.to_json, + headers: owner_headers + + assert_response :unprocessable_entity + assert_equal [ "Email domain is not allowed for this workspace" ], response.parsed_body.fetch("errors") + ensure + OrganizationInviteEmailService.define_singleton_method(:configured?, original_configured) if original_configured + if old_allowed_domains + ENV["ALLOWED_MEMBER_EMAIL_DOMAINS"] = old_allowed_domains + else + ENV.delete("ALLOWED_MEMBER_EMAIL_DOMAINS") + end + end + + test "organization owners can set the school year and archive a classroom read-only" do + owner = User.create!( + clerk_id: "test_clerk_lifecycle_owner", + email: "lifecycle-owner@example.com", + first_name: "Lifecycle", + last_name: "Owner", + role: :mentor + ) + organization = Organization.create!(name: "Lifecycle School", created_by: owner) + organization.organization_memberships.create!(user: owner, role: :owner) + student = User.create!( + clerk_id: "test_clerk_lifecycle_student", + email: "lifecycle-student@example.com", + first_name: "Lifecycle", + last_name: "Student" + ) + student_membership = organization.organization_memberships.create!(user: student, role: :student) + invitation = organization.organization_invitations.create!( + invited_by: owner, + email: "pending-lifecycle-student@example.com", + role: :student + ) + project = owner.projects.create!( + organization: organization, + title: "Class Project", + kind: "ruby", + visibility: "private", + project_files: [ ProjectFile.new(path: "main.rb", language: "ruby", content: "puts 'before'") ] + ) + owner_headers = { + "Authorization" => "Bearer test_token_#{owner.id}", + "Content-Type" => "application/json" + } + + patch "/api/v1/organizations/#{organization.id}", + params: { school_year: "2026–2027" }.to_json, + headers: owner_headers + + assert_response :success + assert_equal "2026–2027", response.parsed_body.dig("organization", "school_year") + + patch "/api/v1/organizations/#{organization.id}/archive", headers: owner_headers + + assert_response :success + assert_not_nil response.parsed_body.dig("organization", "archived_at") + + patch "/api/v1/projects/#{project.id}", + params: { + title: "Changed", + kind: "ruby", + files: [ { path: "main.rb", language: "ruby", content: "puts 'after'" } ] + }.to_json, + headers: owner_headers + + assert_response :unprocessable_entity + assert_equal "puts 'before'", project.reload.project_files.first.content + + patch "/api/v1/organizations/#{organization.id}", + params: { name: "Changed While Archived" }.to_json, + headers: owner_headers + assert_response :unprocessable_entity + assert_equal "Lifecycle School", organization.reload.name + + patch "/api/v1/projects/#{project.id}/archive", headers: owner_headers + assert_response :unprocessable_entity + assert_nil project.reload.archived_at + + post "/api/v1/projects/#{project.id}/checkpoints", + params: { title: "Archived checkpoint" }.to_json, + headers: owner_headers + assert_response :unprocessable_entity + assert_equal 0, project.project_checkpoints.count + + delete "/api/v1/projects/#{project.id}", headers: owner_headers + assert_response :unprocessable_entity + assert Project.exists?(project.id) + + post "/api/v1/organizations/#{organization.id}/invitations/#{invitation.id}/resend", + headers: owner_headers + assert_response :unprocessable_entity + + delete "/api/v1/organizations/#{organization.id}/invitations/#{invitation.id}", + headers: owner_headers + assert_response :unprocessable_entity + assert OrganizationInvitation.exists?(invitation.id) + + invited_student = User.create!( + clerk_id: "test_clerk_archived_invitation_student", + email: invitation.email, + first_name: "Invited", + last_name: "Student" + ) + invited_student_headers = { + "Authorization" => "Bearer test_token_#{invited_student.id}", + "Content-Type" => "application/json" + } + post "/api/v1/invitations/#{invitation.token}/accept", headers: invited_student_headers + assert_response :unprocessable_entity + assert_nil invitation.reload.accepted_at + assert_not organization.organization_memberships.exists?(user: invited_student) + + patch "/api/v1/organizations/#{organization.id}/members/#{student_membership.id}", + params: { role: "instructor" }.to_json, + headers: owner_headers + assert_response :unprocessable_entity + assert_equal "student", student_membership.reload.role + + delete "/api/v1/organizations/#{organization.id}/members/#{student_membership.id}", + headers: owner_headers + assert_response :unprocessable_entity + assert OrganizationMembership.exists?(student_membership.id) + + patch "/api/v1/organizations/#{organization.id}/unarchive", headers: owner_headers + assert_response :success + assert_nil response.parsed_body.dig("organization", "archived_at") + + get "/api/v1/organizations/#{organization.id}/audit_events", headers: owner_headers + assert_response :success + assert_includes response.parsed_body.fetch("audit_events").pluck("action"), "organization.archived" + assert_includes response.parsed_body.fetch("audit_events").pluck("action"), "organization.restored" + end + test "organization owners can manage members and protect the final owner" do owner = User.create!( clerk_id: "test_clerk_member_owner", @@ -915,6 +1233,13 @@ class ProjectsApiTest < ActionDispatch::IntegrationTest owner_membership = organization.organization_memberships.create!(user: owner, role: :owner) organization.organization_memberships.create!(user: instructor, role: :instructor) student_membership = organization.organization_memberships.create!(user: student, role: :student) + student_project = student.projects.create!( + organization: organization, + title: "Student Class Project", + kind: "ruby", + visibility: "organization", + project_files: [ ProjectFile.new(path: "main.rb", language: "ruby", content: "puts 'student'") ] + ) owner_headers = { "Authorization" => "Bearer test_token_#{owner.id}", "Content-Type" => "application/json" @@ -923,6 +1248,19 @@ class ProjectsApiTest < ActionDispatch::IntegrationTest "Authorization" => "Bearer test_token_#{instructor.id}", "Content-Type" => "application/json" } + student_headers = { + "Authorization" => "Bearer test_token_#{student.id}", + "Content-Type" => "application/json" + } + + get "/api/v1/organizations/#{organization.id}/export", headers: student_headers + assert_response :success + assert_equal [ student.id ], response.parsed_body.dig("export", "members").pluck("id") + assert_equal [ student_project.id ], response.parsed_body.dig("export", "projects").pluck("id") + + get "/api/v1/organizations/#{organization.id}/export", headers: instructor_headers + assert_response :success + assert_equal 3, response.parsed_body.dig("export", "members").length patch "/api/v1/organizations/#{organization.id}/members/#{student_membership.id}", params: { role: "instructor" }.to_json, @@ -956,6 +1294,8 @@ class ProjectsApiTest < ActionDispatch::IntegrationTest assert_response :no_content assert_nil OrganizationMembership.find_by(id: student_membership.id) + assert_nil student_project.reload.organization_id + assert_equal "private", student_project.visibility end test "organization students cannot view another student's private organization project" do @@ -1007,13 +1347,19 @@ class ProjectsApiTest < ActionDispatch::IntegrationTest visibility: "organization", project_files: [ ProjectFile.new(path: "main.rb", language: "ruby", content: "puts 'class'") ] ) - unlisted_project = @user.projects.create!( - organization: organization, - title: "Link Only Ruby", - kind: "ruby", - visibility: "unlisted", - project_files: [ ProjectFile.new(path: "main.rb", language: "ruby", content: "puts 'link only'") ] - ) + old_external_sharing = ENV["ALLOW_ORGANIZATION_EXTERNAL_SHARING"] + ENV["ALLOW_ORGANIZATION_EXTERNAL_SHARING"] = "true" + begin + unlisted_project = @user.projects.create!( + organization: organization, + title: "Link Only Ruby", + kind: "ruby", + visibility: "unlisted", + project_files: [ ProjectFile.new(path: "main.rb", language: "ruby", content: "puts 'link only'") ] + ) + ensure + ENV["ALLOW_ORGANIZATION_EXTERNAL_SHARING"] = old_external_sharing + end other_headers = { "Authorization" => "Bearer test_token_#{other_student.id}", "Content-Type" => "application/json" @@ -1040,13 +1386,19 @@ class ProjectsApiTest < ActionDispatch::IntegrationTest ) organization = Organization.create!(name: "Source School", created_by: @user) organization.organization_memberships.create!(user: @user, role: :owner) - source_project = @user.projects.create!( - organization: organization, - title: "Shareable Ruby", - kind: "ruby", - visibility: "unlisted", - project_files: [ ProjectFile.new(path: "main.rb", language: "ruby", content: "puts 'copy me'") ] - ) + old_external_sharing = ENV["ALLOW_ORGANIZATION_EXTERNAL_SHARING"] + ENV["ALLOW_ORGANIZATION_EXTERNAL_SHARING"] = "true" + begin + source_project = @user.projects.create!( + organization: organization, + title: "Shareable Ruby", + kind: "ruby", + visibility: "unlisted", + project_files: [ ProjectFile.new(path: "main.rb", language: "ruby", content: "puts 'copy me'") ] + ) + ensure + ENV["ALLOW_ORGANIZATION_EXTERNAL_SHARING"] = old_external_sharing + end outsider_headers = { "Authorization" => "Bearer test_token_#{outsider.id}", "Content-Type" => "application/json" diff --git a/api/test/jobs/cleanup_expired_data_job_test.rb b/api/test/jobs/cleanup_expired_data_job_test.rb new file mode 100644 index 0000000..55873b1 --- /dev/null +++ b/api/test/jobs/cleanup_expired_data_job_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class CleanupExpiredDataJobTest < ActiveJob::TestCase + test "removes expired shares and unused invitations while keeping current records" do + owner = User.create!( + clerk_id: "cleanup-owner", + email: "cleanup-owner@example.com", + first_name: "Cleanup", + last_name: "Owner" + ) + organization = Organization.create!(name: "Cleanup School", created_by: owner) + expired_invitation = organization.organization_invitations.create!( + invited_by: owner, + email: "expired@example.com", + role: :student, + expires_at: 1.minute.ago + ) + current_invitation = organization.organization_invitations.create!( + invited_by: owner, + email: "current@example.com", + role: :student, + expires_at: 1.day.from_now + ) + expired_share = ProjectShare.create!( + title: "Expired", + kind: "ruby", + snapshot: { files: [ { path: "main.rb", content: "puts 1" } ] }, + expires_at: 1.minute.ago + ) + current_share = ProjectShare.create!( + title: "Current", + kind: "ruby", + snapshot: { files: [ { path: "main.rb", content: "puts 2" } ] }, + expires_at: 1.day.from_now + ) + + CleanupExpiredDataJob.perform_now + + assert_not OrganizationInvitation.exists?(expired_invitation.id) + assert OrganizationInvitation.exists?(current_invitation.id) + assert_not ProjectShare.exists?(expired_share.id) + assert ProjectShare.exists?(current_share.id) + end +end diff --git a/api/test/jobs/organization_invite_email_job_test.rb b/api/test/jobs/organization_invite_email_job_test.rb new file mode 100644 index 0000000..7857457 --- /dev/null +++ b/api/test/jobs/organization_invite_email_job_test.rb @@ -0,0 +1,57 @@ +require "test_helper" + +class OrganizationInviteEmailJobTest < ActiveJob::TestCase + setup do + @owner = User.create!( + clerk_id: "invite-job-owner", + email: "invite-job-owner@example.com", + first_name: "Invite", + last_name: "Owner", + role: :mentor + ) + @organization = Organization.create!(name: "Invite Job School", created_by: @owner) + @organization.organization_memberships.create!(user: @owner, role: :owner) + @invitation = @organization.organization_invitations.create!( + invited_by: @owner, + email: "invite-job-student@example.com", + role: :student, + delivery_status: "queued" + ) + end + + test "records a successful provider delivery" do + original_send_invite = OrganizationInviteEmailService.method(:send_invite!) + OrganizationInviteEmailService.define_singleton_method(:send_invite!) do |**| + { "id" => "provider-message-123" } + end + + OrganizationInviteEmailJob.perform_now(@invitation.id, "https://hafa-code.netlify.app/#invite=test") + + @invitation.reload + assert_equal "sent", @invitation.delivery_status + assert_equal "provider-message-123", @invitation.provider_message_id + assert_equal 1, @invitation.send_attempts + assert_not_nil @invitation.last_sent_at + assert_nil @invitation.delivery_error + ensure + OrganizationInviteEmailService.define_singleton_method(:send_invite!, original_send_invite) + end + + test "records a failed delivery before retrying" do + original_send_invite = OrganizationInviteEmailService.method(:send_invite!) + OrganizationInviteEmailService.define_singleton_method(:send_invite!) do |**| + raise StandardError, "provider unavailable" + end + + assert_enqueued_jobs 1, only: OrganizationInviteEmailJob do + OrganizationInviteEmailJob.perform_now(@invitation.id, "https://hafa-code.netlify.app/#invite=test") + end + + @invitation.reload + assert_equal "failed", @invitation.delivery_status + assert_equal 1, @invitation.send_attempts + assert_equal "provider unavailable", @invitation.delivery_error + ensure + OrganizationInviteEmailService.define_singleton_method(:send_invite!, original_send_invite) + end +end diff --git a/api/test/models/api_rate_limit_test.rb b/api/test/models/api_rate_limit_test.rb new file mode 100644 index 0000000..f8ab307 --- /dev/null +++ b/api/test/models/api_rate_limit_test.rb @@ -0,0 +1,15 @@ +require "test_helper" + +class ApiRateLimitTest < ActiveSupport::TestCase + include ActiveSupport::Testing::TimeHelpers + + test "persists request counts and resets after the time window" do + assert_not ApiRateLimit.exceeded?("test:student", limit: 2, period: 1.hour) + assert_not ApiRateLimit.exceeded?("test:student", limit: 2, period: 1.hour) + assert ApiRateLimit.exceeded?("test:student", limit: 2, period: 1.hour) + + travel 61.minutes do + assert_not ApiRateLimit.exceeded?("test:student", limit: 2, period: 1.hour) + end + end +end diff --git a/api/test/models/audit_event_test.rb b/api/test/models/audit_event_test.rb new file mode 100644 index 0000000..8f6e513 --- /dev/null +++ b/api/test/models/audit_event_test.rb @@ -0,0 +1,10 @@ +require "test_helper" + +class AuditEventTest < ActiveSupport::TestCase + test "does not allow project source to be copied into audit metadata" do + event = AuditEvent.new(action: "unsafe", metadata: { source: "puts 'secret'" }) + + assert_not event.valid? + assert_includes event.errors.full_messages, "Metadata cannot include project source" + end +end diff --git a/api/test/models/project_quota_test.rb b/api/test/models/project_quota_test.rb new file mode 100644 index 0000000..dbb18ed --- /dev/null +++ b/api/test/models/project_quota_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +class ProjectQuotaTest < ActiveSupport::TestCase + test "rejects a project whose combined source exceeds the classroom limit" do + user = User.create!( + clerk_id: "quota-student", + email: "quota-student@example.com", + first_name: "Quota", + last_name: "Student" + ) + project = user.projects.new(title: "Oversized Project", kind: "ruby", visibility: "private") + 5.times do |index| + project.project_files.build( + path: "file#{index}.rb", + language: "ruby", + content: "a" * 450_000, + position: index + ) + end + + assert_not project.valid? + assert_includes project.errors.full_messages, "Project files cannot contain more than 2 MB of source code" + end + + test "counts multibyte content by bytes as well as characters" do + file = ProjectFile.new(path: "main.rb", language: "ruby", content: "å" * 300_000, position: 0) + + assert_not file.valid? + assert_includes file.errors.full_messages, "Content is too large (maximum is 500 KB)" + end +end diff --git a/docs/FDMS_CLASSROOM_LAUNCH_PLAN.md b/docs/FDMS_CLASSROOM_LAUNCH_PLAN.md new file mode 100644 index 0000000..8bd5459 --- /dev/null +++ b/docs/FDMS_CLASSROOM_LAUNCH_PLAN.md @@ -0,0 +1,619 @@ +# FDMS Classroom Launch Readiness and Action Plan + +**Version:** 1.1 +**Audit date:** July 25, 2026 +**Target:** Father Dueñas Memorial School classroom use during the 2026–2027 school year +**Production frontend:** +**Production API:** +**Current recommendation:** **Repository-ready after this hardening PR; production launch remains conditional on the deployment, school, backup, monitoring, and pilot gates below** + +## 1. Executive Summary + +Hafa Code is a Guam-built, open-source browser coding workspace for beginner Ruby, JavaScript, and HTML/CSS/JavaScript projects. It was intentionally built smaller than Replit: students can start coding without installing a development environment, their code runs in browser sandboxes instead of on the Rails server, and signed-in users can save projects to the cloud. + +The application already has a credible classroom foundation: + +- Organizations can represent classes. +- Organization roles distinguish students, instructors, and owners. +- A teacher can see and run every student's class project without being able to overwrite the student's code. +- Students can keep work teacher-only or expose it to classmates. +- Invitations, role management, project history, multi-file projects, sharing, archiving, and mobile layouts already exist. +- The current lint, TypeScript build, Rails tests, RuboCop, and Brakeman checks pass. + +The classroom-hardening branch resolves the verified repository blockers: durable per-project cloud sync, optimistic conflict protection, private feedback threads, class-preserving copies, bulk invitations, durable email jobs, classroom lifecycle/export/audit behavior, quotas and cleanup, accessibility fixes, safer sharing defaults, leaner PWA caching, active root-level CI, and clear dependency audits. Multi-role Rails integration tests and focused React tests cover the most important authorization, saving, feedback, invitation, lifecycle, and accessibility paths. + +The remaining launch gates are operational rather than missing application code: + +1. Deploy the branch and verify the real Netlify, Render, and Clerk production configuration. +2. Obtain FDMS privacy/acceptable-use approval and confirm the school-domain and external-sharing policies. +3. Verify database backups with a restore drill; configure monitoring, alerts, and support ownership. +4. Run a production-safe multi-role smoke test and a 2–4 student pilot on the actual FDMS devices and network. + +The core architecture does not need to be replaced. The next move is to merge and deploy the hardening work, close the external gates, then run the controlled pilot before full enrollment. + +## 2. What Hafa Code Is — and Why It Exists + +Hafa Code is best understood as a **classroom coding workspace**, not a complete learning management system. + +It solves several specific problems: + +- Students can write and run beginner code on school devices without installing Ruby, Node, compilers, or an IDE. +- Ruby and JavaScript run in browser workers with time guardrails. +- Web projects render inside nested sandboxed frames. +- Rails stores users, memberships, project metadata, source files, checkpoints, and share snapshots; it does not execute student code. +- The product is small enough to remain understandable and approachable for Code School of Guam and FDMS students who may eventually contribute to it. +- The visual language and copy give the product a local Guam identity instead of presenting students with a generic enterprise coding tool. + +For the FDMS launch, Hafa Code should remain focused on: + +- writing and running code; +- organizing student work by class; +- teacher visibility and feedback; +- safe sharing; +- dependable saving and recovery. + +Assignments, grades, due dates, rubrics, and official course records should remain in the school's existing LMS during the first school year unless FDMS explicitly decides otherwise. Duplicating a full LMS inside Hafa Code would add scope and risk without improving the coding experience. + +## 3. Verified Production Baseline + +These checks were performed against the production URLs on July 25, 2026, before the hardening branch was deployed. They are baseline evidence, not a claim about the post-merge deployment. + +| Check | Result | Meaning | +| --- | --- | --- | +| Netlify homepage | `200 OK` | The current production frontend is online. | +| Rails `/health` | `200 OK` with `{"status":"ok"}` | The API process is reachable. | +| Netlify security headers | Present | CSP, HSTS, no-sniff, referrer, permissions, and frame protections are configured. | +| Netlify-origin API preflight | Missing `Access-Control-Allow-Origin` | Baseline production cannot use the API from the current origin. The branch fixes the application default; redeployment and a production preflight remain required. | +| Localhost API preflight | Allowed | Current Render CORS configuration appears to allow localhost instead of production. | +| Production page rendering | Successful | The signed-out editor, runner controls, project library, visibility UI, and responsive structure load. | +| Canonical and social metadata | Points to `https://hafacode.com/` | Baseline metadata is stale. The branch aligns canonical, social, robots, and sitemap URLs to Netlify. | +| API authentication/class workflows | Not production-verifiable while CORS is blocked | Must be tested after the origin configuration is corrected. | + +### Correction to the earlier domain finding + +The earlier concern should be stated precisely: + +- The application is available at `https://hafa-code.netlify.app/`. +- The Netlify frontend is not down. +- The production Rails API is healthy. +- The baseline break is that the API does not authorize `https://hafa-code.netlify.app` as a CORS origin. +- `hafacode.com` is a stale or future canonical domain in the baseline metadata, not the URL students should use today. +- The hardening branch declares the Netlify URL as the production application origin and always includes it in CORS, while preserving an environment override for a future domain. + +That makes the finding more actionable: align Render, Netlify, Clerk, invitation links, and metadata around one declared production origin. + +## 4. Recommended FDMS Operating Model + +### Classes + +Create two organizations: + +- `FDMS — [Course/Period 1] — 2026–2027` +- `FDMS — [Course/Period 2] — 2026–2027` + +An organization is the current Hafa Code equivalent of a class/workspace. A student can belong to both organizations and switch between them. + +### Roles + +| Person | Hafa Code role | Why | +| --- | --- | --- | +| Shimizu platform operator | Platform `admin` | Provisioning, support, and emergency access only. | +| Primary teacher | Organization `owner` | Can invite people, manage roles, remove members, and see all class work. | +| Co-teacher or assistant | Organization `instructor` | Can invite students and see all work without changing roles. | +| Student | Organization `student` | Owns and edits their projects. | + +The teacher should **not** be made a platform administrator. Platform admin access spans every organization and private project; organization owner is the correctly scoped role. + +Only a platform admin, mentor, or an account allowed by environment configuration can create an organization. The practical setup flow is: + +1. A platform admin creates both FDMS organizations. +2. The admin invites the teacher as an instructor. +3. The admin promotes the teacher to owner. +4. The teacher invites students into the correct class. +5. Each organization keeps at least one owner. + +### Visibility language and behavior + +The product should use classroom-friendly labels: + +| Recommended label | Current value | Who can see it | +| --- | --- | --- | +| Teacher only | `private` | Student owner, organization instructors/owners, and platform admins | +| Class | `organization` | Everyone in that organization | +| Anyone with link | `unlisted` | Any signed-in Hafa Code user who has or discovers the project ID | +| Public | `public` | Any signed-in Hafa Code user; class members can also find it in class lists | + +Important distinctions: + +- “Teacher only” is not literally limited to one teacher; other organization owners/instructors and platform admins can also view it. +- Current live `unlisted` and `public` projects still require Hafa Code sign-in. +- Current project IDs are numeric, so `unlisted` is not a strong opaque-link permission. +- The separate snapshot sharing feature creates an anonymous copy that expires after 30 days. It does not create a live view of the original project. + +For the initial FDMS launch, default every class project to **Teacher only**, offer **Class** when peer review is intended, and disable or hide Internet-facing sharing until FDMS approves a written policy. + +## 5. Current Capability Matrix + +| Classroom need | Current state | Launch decision | +| --- | --- | --- | +| Two separate classes | Supported with two organizations | Use as designed. | +| Teacher manages each class | Supported with organization owner | Use owner, not platform admin. | +| Invite students | Supported individually or by pasted roster | Use bulk invitations for each class and review any per-address errors. | +| Student in both classes | Supported | Test context switching with a real dual-enrollment account. | +| Teacher sees all student work | Supported | Teacher receives read/run access to private class projects. | +| Teacher edits student source | Intentionally not supported | Keep read-only; use comments for feedback. | +| Teacher comments on work | Implemented as private project feedback threads | Teacher/student can reply and resolve; classmates cannot read private threads. | +| Student keeps work teacher-only | Supported as `private` in an organization | Rename the label for clarity. | +| Student shares with class | Supported as `organization` | Rename to `Class`. | +| Public/anyone-with-link work | Partially supported with important caveats | Disable for launch or redesign permissions. | +| Starter project copied into class | Implemented | Copies remain in the class and default to Teacher only. | +| Assignment/due date/grade/rubric | Not implemented and explicitly deferred | Use the school LMS in year one. | +| Reliable full-year autosave | Hardened with local pending state, retries, reconnect, visible status, and optimistic locking | Verify again during the production pilot and monitor failures. | +| Recover older version | Up to 30 checkpoints per project | Keep; add quota/retention policy. | +| Remove student at term end | Implemented | Class work moves to the student's private Personal workspace before membership removal. | +| Teacher exports class work | Implemented as a class JSON export | Run and retain an export before term-end offboarding. | +| Classroom activity/audit record | Implemented for sensitive class actions | Owners can review the latest 100 events; source code is excluded. | + +## 6. Prioritized Work + +### Priority definitions + +- **P0 — launch gate:** Complete before students depend on the platform, unless a written temporary operating procedure explicitly covers the gap. +- **P1 — full-class readiness:** Complete before the initial pilot expands to both classes or before the risk is encountered. +- **P2 — first-semester improvement:** Valuable after the core workflow is stable. + +## 6.1 P0 — Launch Gates + +### FDMS-001 — Align production origins and prove authenticated production access + +**Why:** The production UI loads, but the Rails API does not currently return CORS permission for the Netlify origin. A healthy API is not useful if browsers cannot call it. + +**Work:** + +- [ ] Set Render `ALLOWED_ORIGINS` to include `https://hafa-code.netlify.app`. +- [ ] Set Render `FRONTEND_URL` or `APP_URL` to `https://hafa-code.netlify.app` so invitation links use the live site. +- [ ] Verify Netlify `VITE_API_URL` points to `https://hafa-code.onrender.com`. +- [ ] Verify Clerk production allowed origins and redirect URLs include the Netlify domain. +- [ ] Decide whether `hafacode.com` will be launched now or later. +- [x] Change canonical, Open Graph, Twitter, JSON-LD, robots, sitemap, and share image URLs to the declared production domain. +- [ ] If both a custom domain and Netlify domain remain valid, configure redirects and allow both origins deliberately. + +**Acceptance criteria:** + +- A preflight from the production origin returns the exact `Access-Control-Allow-Origin`. +- A real production student can sign in, load projects, create a project, edit it, reload, and see it again. +- A teacher can sign in, load both class workspaces, and view a student's private class project. +- A production invitation opens the correct domain and can be accepted. +- There are no unexpected browser console or network errors in those flows. + +### FDMS-002 — Make cloud saving durable and honest + +**Why:** The current 900 ms debounce is tied to the active project. Switching projects or workspaces cancels the pending timer. More seriously, cloud merge deliberately retains no local-only projects in an organization context, so an unsynced organization draft can disappear when cloud projects load. + +**Work:** + +- [x] Replace the single active-project timer with a per-project dirty queue. +- [x] Persist dirty state locally until the API confirms a successful save. +- [x] Never discard a local UUID organization project during cloud merge. +- [x] Reconcile a local draft with its new server ID only after creation succeeds. +- [x] Flush or preserve dirty data on project switch, workspace switch, sign-out, and page close. +- [x] Add bounded retry with clear failed state; do not loop indefinitely. +- [x] Show persistent `Saving`, `Saved`, `Offline`, and `Save failed` states near the project title. +- [x] Remove wording that claims cloud backup when the cloud request has not succeeded. +- [x] Add conflict protection using a revision, lock version, or `updated_at` precondition. + +**Acceptance criteria:** + +- Editing and immediately switching projects does not lose the edit. +- Editing and immediately switching organizations does not lose the edit. +- Going offline, editing, reloading, and reconnecting preserves and eventually syncs the draft. +- A failed create remains visible as a local pending project. +- Two tabs editing the same project do not silently overwrite each other. +- Automated tests cover each failure path. + +### FDMS-003 — Keep class copies inside the class + +**Why:** The frontend duplication helper explicitly sets `organizationId`, owner, and organization to `null`. If a teacher publishes a starter project and a student clicks Duplicate, the student's copy becomes a personal project and disappears from the teacher's class view. + +**Work:** + +- [x] When duplicating a class project, default the destination to the active class. +- [ ] Allow a destination chooser only when the user belongs to multiple valid contexts. +- [x] Preserve private visibility for the student's new copy. +- [x] Use the server duplicate endpoint for signed-in cloud projects or make the frontend behavior match it. +- [ ] Clearly show the destination before confirmation. + +**Acceptance criteria:** + +- A student duplicates a class starter and the new project appears under that class. +- The teacher immediately sees the copy in the student's project list. +- A student can intentionally copy to Personal only when that choice is explicitly available. +- Cross-class copying never happens without a deliberate destination selection. + +### FDMS-004 — Add a minimal teacher feedback system + +**Why:** Read-only access is useful, but the requested teaching workflow includes commenting. Without feedback, the teacher must move every review conversation into another tool. + +**Minimum scope:** + +- [x] Add project comments with author, body, timestamp, and resolved state. +- [x] Allow the project owner, organization instructors/owners, and platform admins to read comments. +- [x] Allow those same classroom participants to reply. +- [x] Keep comments private to the student and teaching staff by default, even when the project is Class-visible. +- [x] Support an optional file path and line number, but do not block launch if the first release is project-level only. +- [x] Show unread feedback to the student and unresolved threads to the teacher. +- [x] Record edits/deletions or disallow destructive comment editing after a short window. + +**Acceptance criteria:** + +- A teacher leaves feedback on a private student project. +- Only that student and authorized teaching staff can see the thread. +- The student replies and marks the thread resolved, or the teacher resolves it. +- Removing a student does not expose the thread to unrelated users. +- Authorization tests cover student, classmate, teacher, outsider, and platform admin. + +**Temporary alternative:** If this cannot be completed before the pilot, FDMS must explicitly agree that feedback will stay in its existing LMS for the pilot. It should not be an accidental missing workflow. + +### FDMS-005 — Repair CI and clear high-severity dependency advisories + +**Why:** The local quality checks are useful, but GitHub does not execute workflows stored under `api/.github/workflows`. Current production dependency audits also report high-severity JavaScript and Ruby advisories. + +**Work:** + +- [x] Move the workflow to root `.github/workflows/ci.yml`. +- [x] Give Rails jobs `working-directory: api` or equivalent commands. +- [x] Add Node setup, frontend install, lint, build, and production dependency audit. +- [x] Run Rails tests, RuboCop, Brakeman, and Bundler Audit in CI. +- [x] Pin a supported Node version in the repository; the current machine uses Node 22.22.3 and Vite 8 requires a modern Node runtime. +- [x] Update affected frontend dependencies, including the DOMPurify and `js-cookie` dependency chains. +- [x] Update affected Ruby dependencies, prioritizing `jwt`, `puma`, and `websocket-driver`, then the remaining advisories. +- [x] Rebuild and rerun all tests after lockfile updates. +- [ ] Make passing CI required before merging to `main`. + +**Acceptance criteria:** + +- A pull request produces visible frontend, backend, style, static security, and dependency checks. +- The workflow passes from a clean GitHub runner. +- `npm audit --omit=dev --audit-level=high` passes. +- `bundle exec bundler-audit check` passes or every exception is narrowly documented with applicability, owner, and expiry date. + +### FDMS-006 — Add multi-role end-to-end classroom tests + +**Why:** Thirty-two Rails integration tests and clean static scans are a good base, but they do not prove that Clerk, React, Render, Netlify, invitations, and role-specific UI work together. + +**Test accounts:** + +| Account | Purpose | +| --- | --- | +| Platform admin | Provisioning and emergency-access boundaries | +| Teacher owner | Full class management | +| Teacher instructor | Roster and student-work access without owner powers | +| Student A | Private work and feedback | +| Student B | Class visibility and classmate isolation | +| Dual-class student | Context switching | +| Signed-in outsider | Public/unlisted/private boundaries | +| Removed student | Offboarding behavior | + +**Automated critical flows:** + +- [ ] Sign in and authorization resolution. +- [ ] Create both classes and assign the teacher owner role. +- [ ] Invite, resend, revoke, accept, reject wrong-email acceptance, and handle expiration. +- [ ] Create/edit/reload a private class project. +- [ ] Teacher views but cannot edit a student's project. +- [ ] Classmate cannot view private work but can view Class work. +- [ ] Duplicate a starter into the correct class. +- [ ] Comment/reply/resolve feedback. +- [ ] Archive, restore, checkpoint, and delete. +- [ ] Remove a student and verify the selected lifecycle behavior. +- [ ] Exercise mobile and keyboard-critical paths. + +**Acceptance criteria:** + +- The suite runs in CI. +- A smaller smoke suite runs against staging or a production-safe test tenant after deployment. +- No launch-critical flow depends only on a developer's manual memory. + +### FDMS-007 — Obtain school privacy approval and define sharing rules + +**Why:** Student names, email addresses, class membership, source code, teacher comments, and timestamps may become education records. This is an operational and contractual requirement, not just a code feature. + +**Work with FDMS:** + +- [ ] Confirm the school administration and IT representative approve Hafa Code for classroom use. +- [ ] Document what data is collected, why it is collected, where it is hosted, and which subprocessors are used. +- [ ] Document authorized use, access control, support access, redisclosure limits, retention, deletion, export, and incident notification. +- [ ] Decide whether student public/unlisted projects and anonymous snapshot links are allowed. +- [ ] Decide whether only school-domain accounts may join FDMS classes. +- [ ] Publish a plain-language privacy notice and terms/acceptable-use policy. +- [ ] Define who can request export or deletion and how quickly requests are handled. +- [ ] Document that platform administrators can technically access private class projects and limit that access by policy and audit logging. + +U.S. Department of Education guidance says teachers should first check whether a tool is school-approved. When the school-official exception is used, the service must be under the school's direct control for use and maintenance of education-record information, use must match the school's notice, and the data must not be reused or redisclosed for unauthorized purposes. The Department also calls for reasonable controls that restrict records to officials with legitimate educational interests. + +References: + +- [Classroom application and FERPA FAQ](https://studentprivacy.ed.gov/faq/i-want-use-online-tool-or-application-part-my-course-however-i-am-worried-it-violation-ferpa) +- [Protecting Student Privacy While Using Online Educational Services](https://studentprivacy.ed.gov/resources/protecting-student-privacy-while-using-online-educational-services-requirements-and-best) +- [Legitimate educational interest access controls FAQ](https://studentprivacy.ed.gov/faq/what-must-educational-agencies-or-institutions-do-ensure-only-school-officials-legitimate) + +This plan is a product and engineering checklist, not legal advice. + +### FDMS-008 — Prove backup, restore, monitoring, and incident ownership + +**Why:** A full school year requires recovery and support processes. The repository alone cannot confirm Render database backups, service plan limits, or restore procedures, and no application error-monitoring integration is present in the code. + +**Work:** + +- [ ] Verify the production database plan, backup frequency, retention, and point-in-time recovery capability. +- [ ] Perform a restore drill into a non-production database. +- [ ] Add application error monitoring for Rails and React with release identifiers. +- [ ] Add uptime checks for the Netlify app and Rails health endpoint. +- [ ] Alert on elevated API errors, authentication failures, email failures, and save failures. +- [ ] Name a primary and backup support owner during school hours. +- [ ] Write a short incident runbook covering outage, lost work, compromised account, accidental public sharing, and data deletion. +- [ ] Establish a staging environment or isolated classroom test tenant. + +**Acceptance criteria:** + +- A documented restore has succeeded. +- A synthetic error is visible in monitoring with environment and release context. +- A failed health check reaches the named owner. +- The teacher knows where to report an issue and what response time to expect. + +## 6.2 P1 — Full-Class Readiness + +### FDMS-101 — Improve roster and invitation operations + +- [x] Add paste-list or CSV invitations for 15–20 students. +- [x] Prevent duplicate pending invitations for the same organization/email. +- [x] Add optional allowed-domain enforcement. +- [x] Move email delivery out of the request into a durable background job. +- [x] Record provider message ID, delivery state, failure reason, sender, and timestamps. +- [x] Keep resend and revoke controls. +- [x] Stop returning the invitee email from the public invitation-token lookup unless the UI genuinely needs it. +- [x] Verify the invite-only signup policy: a pending organization invitation must be able to create/link the local user even when open signup is disabled. + +### FDMS-102 — Paginate project libraries and load source lazily + +**Why:** The project and organization list endpoints include every file and its full contents. This is fine for a new 20-person class but will become slow as each student accumulates projects. + +- [ ] Return project metadata from list endpoints. +- [x] Add pagination and stable ordering. +- [ ] Fetch full files only when a project is opened. +- [ ] Give the teacher dashboard student, status, visibility, and updated-time filters. +- [ ] Load one student's projects on demand instead of materializing the whole class library in the editor. + +### FDMS-103 — Define quotas and cleanup + +- [ ] Set total bytes per project, student, and organization. +- [x] Review the current theoretical project maximum of 50 files × 500,000 characters and enforce a 2 MiB combined-source cap. +- [x] Account for up to 30 full project checkpoints and enforce source-size validation. +- [x] Add scheduled deletion of expired anonymous shares. +- [x] Use a durable database-backed rate-limit store. +- [ ] Add storage-usage visibility and warnings before rejecting work. + +### FDMS-104 — Complete class lifecycle and offboarding + +- [x] Add school year/term metadata and class archive state. +- [x] Define what happens to projects when a student leaves a class. +- [x] Let the teacher export a student's work and an entire class. +- [x] Let students export their own work before access ends. +- [x] Prevent removed students from being stranded with organization-owned projects they can no longer reach. +- [ ] Define ownership and deletion behavior after graduation or account deletion. + +### FDMS-105 — Add an audit trail + +Record at minimum: + +- organization creation and archival; +- invitations, resends, revocations, and acceptance; +- role changes and member removal; +- public/unlisted visibility changes and anonymous share creation; +- project deletion and administrative access to private student work; +- export and deletion requests. + +The log should capture actor, action, target, organization, timestamp, and relevant before/after values without copying project source into the log. + +**Implementation status:** Complete for the listed application actions, including explicitly enabled classroom snapshot shares. Owners can read the latest 100 class events. Retention and long-term export of the audit log remain an operational policy decision. + +### FDMS-106 — Complete accessibility and device QA + +- [x] Add Escape-to-close, focus trap, initial focus, and focus return for every modal. +- [ ] Test full keyboard operation of project, file, history, sharing, and classroom controls. +- [ ] Test with VoiceOver and at least one other screen reader/browser combination. +- [ ] Verify 200% zoom and narrow Chromebook/mobile widths. +- [ ] Recheck color-safe mode and all status states without relying on color alone. +- [ ] Confirm touch targets are at least 44 px where practical. +- [ ] Test the exact school devices, browser versions, content filters, and network. + +### FDMS-107 — Reduce first-load and PWA cache cost + +The baseline production service worker precaches 102 generated assets. The hardening build limits the install-time cache to the lightweight application shell; language workers and the roughly 36 MiB Ruby standard-library WebAssembly asset are fetched on demand. + +- [ ] Measure first visit, repeat visit, offline start, and update behavior on the FDMS network. +- [ ] Load language runtimes only when the corresponding project type is opened. +- [ ] Limit Monaco languages and workers to the languages Hafa Code supports. +- [x] Avoid precaching every generated language asset. +- [ ] Display runtime-loading progress and actionable offline errors. +- [ ] Verify service-worker updates do not leave students on mismatched frontend assets. + +### FDMS-108 — Harden public and web-preview behavior + +- [ ] Replace numeric live-project unlisted access with an opaque capability token if live unlisted links remain. +- [ ] Decide whether Public means authenticated Hafa Code users or the open Internet, and label it accurately. +- [x] Disable classroom Public, Unlisted, and snapshot sharing by default behind an explicit operator flag. +- [ ] Review automatic execution when a teacher opens a student's web project. +- [x] Remove `allow-modals` from the web-preview sandbox. +- [ ] Confirm expected restrictions on fetches, navigation, forms, popups, remote images, and tracking requests. +- [ ] Keep the nested sandbox and strict preview CSP; update security documentation to match the actual sandbox flags. + +## 6.3 P2 — First-Semester Improvements + +- [ ] Build a dedicated teacher dashboard instead of keeping all classroom controls inside the editor. +- [ ] Add saved filters for “needs feedback,” “recently updated,” and “unresolved.” +- [ ] Add optional notifications for new feedback and replies. +- [ ] Add class starter/template management once class-preserving duplication is reliable. +- [ ] Consider LMS links or lightweight assignment references, not gradebook replacement. +- [ ] Split the 1,842-line `App.tsx` and 2,283-line `App.css` into the component boundaries already identified in the frontend structure plan. +- [ ] Add product analytics only after school privacy approval, with student-safe configuration and no source-code capture. + +## 7. Four-Week Launch Sequence + +This is an order of operations, not a guaranteed calendar estimate. Each phase must pass its gate before the next one expands the number of real users. + +### Week 1 — Production and data foundation + +- Complete FDMS-001 production-origin configuration. +- Complete FDMS-002 save durability design and core implementation. +- Complete FDMS-003 class-preserving duplication. +- Repair CI and start dependency updates from FDMS-005. +- Provision a staging/test tenant and the role matrix. + +**Gate:** Production student save/reload and teacher private-project view pass end to end. + +### Week 2 — Classroom workflow + +- Complete the minimum project-level version of FDMS-004 feedback. +- Complete invite-only account/linking fixes and duplicate-invite protection. +- Implement the P0 end-to-end tests. +- Decide labels and public-sharing policy. + +**Gate:** Teacher can invite a student, see private work, leave feedback, receive a reply, and never edit the student's source. + +### Week 3 — Privacy, operations, and school-device QA + +- Complete the FDMS privacy and acceptable-use review. +- Verify backups, run a restore drill, and add monitoring. +- Test on actual FDMS devices and network. +- Resolve P0 accessibility findings. +- Train the teacher with both class workspaces. + +**Gate:** Written school approval, successful restore evidence, monitoring alerts, and teacher sign-off. + +### Week 4 — Controlled pilot + +- Start with the teacher and 2–4 test students. +- Run at least two real coding sessions. +- Exercise offline/reconnect, class switching, starter duplication, feedback, checkpoint restore, and member removal. +- Review support tickets, save failures, performance, and confusing UI. +- Fix launch-critical pilot findings before enrolling both classes. + +**Gate:** No unresolved data-loss, authorization, enrollment, or feedback blockers. + +## 8. Launch Checklists + +### Before the pilot + +- [ ] Production origin and invitation URLs are correct. +- [ ] High-severity dependency audits are clear. +- [ ] CI runs on every pull request. +- [ ] Save failure and recovery scenarios pass. +- [ ] Class-preserving duplication passes. +- [ ] Teacher feedback workflow is implemented or FDMS accepts the documented LMS fallback. +- [ ] Public sharing is disabled or governed by an approved policy. +- [ ] Test accounts for every role exist. +- [ ] Backup restore and monitoring checks pass. +- [ ] School administration/IT approval is recorded. +- [ ] Teacher training and support contact are complete. + +### Before both classes enroll + +- [ ] Pilot findings are closed or explicitly accepted. +- [ ] Actual student roster import/invitation rehearsal passes. +- [ ] Dual-class context switching passes. +- [ ] School Chromebook/browser/network performance is acceptable. +- [ ] Teacher can find every student's recent work without loading the entire class payload. +- [ ] Offboarding/export behavior is documented. +- [ ] Incident and privacy request procedures are available. + +### Ongoing during the school year + +- [ ] Review failed saves, API errors, and invitation failures weekly during the first month. +- [ ] Review dependency and security alerts at least monthly. +- [ ] Test a database restore on a defined schedule. +- [ ] Review platform-admin access and public-sharing audit events. +- [ ] Export or archive classes at term boundaries. +- [ ] Reconfirm retention and deletion at year end. + +## 9. Decisions Leon and FDMS Need to Make + +| Decision | Why it matters | Recommended default | +| --- | --- | --- | +| Are 15–20 students total or per class? | Affects roster, payload, and support testing. | Test for 20 per class even if enrollment is lower. | +| What existing LMS does FDMS use? | Determines where assignments, grades, and official feedback live. | Keep grades/due dates in that LMS. | +| Is feedback required inside Hafa Code at launch? | Determines whether FDMS-004 is an absolute gate. | Yes, at least project-level private threads. | +| Can students publish outside the class? | Changes privacy and moderation requirements. | No by default. | +| Are personal projects allowed on school accounts? | Affects visibility, retention, and duplication. | Allow, but make class context unmistakable. | +| Must accounts use an FDMS email domain? | Reduces wrong-account and outsider enrollment risk. | Enforce for FDMS organizations if practical. | +| Is there a co-teacher or substitute? | Prevents a single-owner support problem. | Keep two organization owners where possible. | +| What is the retention period? | Drives archive, export, deletion, and backup policy. | School year plus an agreed grace period. | +| Who handles support during class? | Reduces downtime and teacher uncertainty. | Name a primary and backup Shimizu contact. | +| Which domain is canonical? | Aligns CORS, Clerk, links, SEO, and documentation. | Use the Netlify domain now; move once to a custom domain later. | + +## 10. Explicitly Out of Scope for the Initial FDMS Launch + +Unless FDMS changes the requirements, do not make these launch blockers: + +- a full gradebook; +- rubrics; +- due dates and submission windows; +- autograding; +- real-time multiplayer editing; +- server-side execution of student code; +- package installation or a general-purpose terminal; +- replacing the school's LMS; +- an open public project gallery. + +## 11. Audit Evidence + +### Code and architecture reviewed + +- `README.md` +- `docs/PRODUCT_SPEC.md` +- `docs/ARCHITECTURE.md` +- `docs/SECURITY.md` +- `docs/CLASSROOM_ORGS_AND_SHARING_PLAN.md` +- `docs/FRONTEND_STRUCTURE.md` +- Rails routes, models, authorization concerns, controllers, services, migrations, environment configuration, and integration tests +- React application state, storage merge, API client, authentication context, runner, preview sandbox, service worker, headers, manifest, and build configuration + +### Checks run on July 25, 2026 + +| Check | Result | +| --- | --- | +| `npm --prefix web run lint` | Pass | +| `npm --prefix web run build` | Pass, with large-chunk warnings | +| `npm --prefix web test` | Pass: 4 files, 9 tests | +| `bundle exec rails test` | Pass: 49 runs, 398 assertions | +| `bundle exec rubocop` | Pass: 73 files, no offenses | +| `bundle exec brakeman --no-pager` | Pass: 0 warnings | +| `npm audit --audit-level=high` | Pass: 0 vulnerabilities | +| `bundle exec bundler-audit check` | Pass: no vulnerabilities | +| Local multi-role API workflow | Pass: teacher/student/classmate feedback, private isolation, bulk invite, export, archive, audit, CORS, and stale-save conflict | +| Local visible browser smoke test | Pass: editor loads, Ruby runs, and no unexpected console errors | +| Baseline Netlify production page and headers | Reachable; security headers present | +| Baseline Render health endpoint | Healthy | +| Baseline Netlify-origin Render CORS preflight | Fail before deployment; hardening branch adds the production origin by default | +| Hardening-build service worker inventory | 13 application-shell entries; language runtimes and workers load on demand | + +### Confidence labels + +- **Confirmed fixed and tested in the hardening branch:** permissions, private feedback, durable save/recovery, stale-write conflict copies, class-preserving duplication, invitation operations, archived-class immutability, export/offboarding, audit logging, class sharing defaults, quotas/cleanup, active CI placement, dependency advisories, metadata, PWA cache inventory, and modal keyboard behavior. +- **Confirmed only in the pre-deployment production baseline:** the Netlify app and Render health endpoint are reachable, while the old deployment's CORS and metadata are stale. +- **Requires external configuration verification:** Clerk production settings, Render/Netlify environment variables beyond observable behavior, database plan and backups, restore capability, service billing limits, DNS ownership, email-provider delivery health, school device/network policies, and school approval. +- **Product decision:** public sharing, exact feedback scope, use of an existing LMS, personal projects, retention duration, support service level, and canonical domain. + +## 12. Definition of “Solid Enough to Launch” + +Hafa Code is ready for the FDMS pilot when: + +1. students can reliably sign in, join the correct class, create or duplicate class work, save it, reload it, and recover from an interrupted connection; +2. the teacher can manage the roster, view every student's teacher-only work, and give feedback without receiving edit authority over student source; +3. classmates and outsiders cannot access work beyond the student's chosen and school-approved visibility; +4. automated tests prove the role boundaries and critical workflows; +5. dependency scans and CI are active and passing; +6. backups, restore, monitoring, incident response, privacy, retention, and support ownership are documented and exercised; and +7. a small real-world pilot on FDMS devices completes without unresolved data-loss, access-control, or enrollment defects. + +The existing platform is a strong foundation for that outcome. The next month should be spent hardening the classroom workflow and operational guarantees, not rebuilding the editor or expanding into a full LMS. diff --git a/web/index.html b/web/index.html index 4adced4..0d6432c 100644 --- a/web/index.html +++ b/web/index.html @@ -18,26 +18,26 @@ - + - + - + - + - + @@ -49,7 +49,7 @@ "@type": "WebApplication", "name": "Hafa Code", "alternateName": "Hafa Code Playground", - "url": "https://hafacode.com/", + "url": "https://hafa-code.netlify.app/", "description": "A beginner-friendly browser coding playground for Ruby, JavaScript, HTML, and CSS.", "applicationCategory": "EducationalApplication", "operatingSystem": "Any", diff --git a/web/package-lock.json b/web/package-lock.json index c2f0166..a291700 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,12 +15,15 @@ "@ruby/wasm-wasi": "^2.9.3-2.9.4", "clsx": "^2.1.1", "lucide-react": "^1.14.0", + "monaco-editor": "0.55.1", "quickjs-emscripten": "^0.32.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -29,19 +32,42 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", + "jsdom": "^26.1.0", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", - "vite": "^8.0.10" + "vite": "8.0.16", + "vitest": "^4.0.18" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "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" }, @@ -50,9 +76,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "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": { @@ -60,22 +86,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "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", - "peer": true, "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@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", @@ -92,14 +117,14 @@ } }, "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.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -109,14 +134,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" @@ -126,9 +151,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": { @@ -136,219 +161,826 @@ } }, "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.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "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": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "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": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "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": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "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.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "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.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bjorn3/browser_wasi_shim": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@bjorn3/browser_wasi_shim/-/browser_wasi_shim-0.4.2.tgz", + "integrity": "sha512-/iHkCVUG3VbcbmEHn5iIUpIrh7a7WPiwZ3sHy4HZKZzBdSadwdddYDZAII2zBvQYV0Lfi8naZngPCN7WPHI/hA==", + "license": "MIT OR Apache-2.0" + }, + "node_modules/@clerk/clerk-react": { + "version": "5.61.9", + "resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.61.9.tgz", + "integrity": "sha512-PRIodE1QVem/eV3wrjicJJiJ2pkGiZfI6t1vekE/+04cIHciXyvUezQ/468gGPl31xd7BiCNO3uKNM14TXEKZQ==", + "license": "MIT", + "dependencies": { + "@clerk/shared": "^3.47.8", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18.17.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", + "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" + } + }, + "node_modules/@clerk/shared": { + "version": "3.47.8", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.47.8.tgz", + "integrity": "sha512-cPURU1it/Eal4uXO9SOcHzw9sfE6Opi21W8EJUg+Ri80Q6JKtK8qBmyOpIyqgf09EanexhTzS4xrmYlvn2p7Iw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "csstype": "3.1.3", + "dequal": "2.0.3", + "glob-to-regexp": "0.4.1", + "js-cookie": "3.0.7", + "std-env": "^3.9.0", + "swr": "2.3.4" + }, + "engines": { + "node": ">=18.17.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", + "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "node": ">=18" } }, - "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==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "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==", + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "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==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" - }, + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, "engines": { - "node": ">=6.0.0" + "node": ">=18" } }, - "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==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "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==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, + "optional": true, + "os": [ + "sunos" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@bjorn3/browser_wasi_shim": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@bjorn3/browser_wasi_shim/-/browser_wasi_shim-0.4.2.tgz", - "integrity": "sha512-/iHkCVUG3VbcbmEHn5iIUpIrh7a7WPiwZ3sHy4HZKZzBdSadwdddYDZAII2zBvQYV0Lfi8naZngPCN7WPHI/hA==", - "license": "MIT OR Apache-2.0" - }, - "node_modules/@clerk/clerk-react": { - "version": "5.61.6", - "resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.61.6.tgz", - "integrity": "sha512-OiyBlrnkRr9IhZtPd7EwlzhYScBpvNKJ8lgg7Uw6JElzJYz854IeQaez5mAfpiib3LcW/Dn53E2PQhagcuLJ3Q==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@clerk/shared": "^3.47.5", - "tslib": "2.8.1" - }, + "optional": true, + "os": [ + "win32" + ], + "peer": true, "engines": { - "node": ">=18.17.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", - "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" + "node": ">=18" } }, - "node_modules/@clerk/shared": { - "version": "3.47.5", - "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.47.5.tgz", - "integrity": "sha512-rDVe73/VN2NZXhtrLRHshkUpQDrevAqDRxeXUl2M0IBEBkcl+VMHlV7fep53cVWo0b3gIqLk82pmmi+WoyF/xg==", - "hasInstallScript": true, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "dependencies": { - "csstype": "3.1.3", - "dequal": "2.0.3", - "glob-to-regexp": "0.4.1", - "js-cookie": "3.0.5", - "std-env": "^3.9.0", - "swr": "2.3.4" - }, + "optional": true, + "os": [ + "win32" + ], + "peer": true, "engines": { - "node": ">=18.17.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", - "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@clerk/shared/node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { @@ -403,9 +1035,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", - "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -460,9 +1092,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", - "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -655,14 +1287,13 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, - "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -674,24 +1305,22 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, - "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -701,14 +1330,13 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -718,14 +1346,13 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -735,14 +1362,13 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -752,14 +1378,13 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -769,14 +1394,13 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -786,14 +1410,13 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -803,14 +1426,13 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -820,14 +1442,13 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -837,14 +1458,13 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -854,14 +1474,13 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -871,14 +1490,13 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openharmony" @@ -888,14 +1506,13 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], "dev": true, - "license": "MIT", "optional": true, "dependencies": { "@emnapi/core": "1.10.0", @@ -907,14 +1524,13 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -923,60 +1539,154 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", - "cpu": [ - "x64" - ], + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ruby/3.3-wasm-wasi": { + "version": "2.9.3-2.9.4", + "resolved": "https://registry.npmjs.org/@ruby/3.3-wasm-wasi/-/3.3-wasm-wasi-2.9.3-2.9.4.tgz", + "integrity": "sha512-T9hqa2zxH8eWpn0Kb1UUSfcIprdPkqxLiNgilyvDVkADstpy821YL4oc87W1TDf/gUpnx2xtjQ8VwRuepoWTiw==", + "license": "MIT", + "dependencies": { + "@ruby/wasm-wasi": "2.9.3-2.9.4" + } + }, + "node_modules/@ruby/wasm-wasi": { + "version": "2.9.3-2.9.4", + "resolved": "https://registry.npmjs.org/@ruby/wasm-wasi/-/wasm-wasi-2.9.3-2.9.4.tgz", + "integrity": "sha512-WxW9wON/TIf+8Ktng8qDJeV/6iH8kw+YwxOsOyXdAdLJgfYDPAXOqpIVd/96y2C9V8VJ2yqZm/IRv6nLeV6EKg==", + "license": "MIT", + "dependencies": { + "@bjorn3/browser_wasi_shim": "^0.4.2", + "tslib": "^2.8.1" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.7", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", - "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, - "license": "MIT" - }, - "node_modules/@ruby/3.3-wasm-wasi": { - "version": "2.9.3-2.9.4", - "resolved": "https://registry.npmjs.org/@ruby/3.3-wasm-wasi/-/3.3-wasm-wasi-2.9.3-2.9.4.tgz", - "integrity": "sha512-T9hqa2zxH8eWpn0Kb1UUSfcIprdPkqxLiNgilyvDVkADstpy821YL4oc87W1TDf/gUpnx2xtjQ8VwRuepoWTiw==", - "license": "MIT", + "optional": true, "dependencies": { - "@ruby/wasm-wasi": "2.9.3-2.9.4" + "tslib": "^2.4.0" } }, - "node_modules/@ruby/wasm-wasi": { - "version": "2.9.3-2.9.4", - "resolved": "https://registry.npmjs.org/@ruby/wasm-wasi/-/wasm-wasi-2.9.3-2.9.4.tgz", - "integrity": "sha512-WxW9wON/TIf+8Ktng8qDJeV/6iH8kw+YwxOsOyXdAdLJgfYDPAXOqpIVd/96y2C9V8VJ2yqZm/IRv6nLeV6EKg==", + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, "license": "MIT", - "dependencies": { - "@bjorn3/browser_wasi_shim": "^0.4.2", - "tslib": "^2.8.1" - } + "peer": true }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -985,9 +1695,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -999,23 +1709,21 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1030,6 +1738,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react/node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1038,17 +1753,17 @@ "optional": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", - "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/type-utils": "8.59.1", - "@typescript-eslint/utils": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1061,15 +1776,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.1", + "@typescript-eslint/parser": "^8.65.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -1077,17 +1792,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", - "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3" }, "engines": { @@ -1103,14 +1817,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", - "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.1", - "@typescript-eslint/types": "^8.59.1", + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "engines": { @@ -1125,14 +1839,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", - "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1" + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1143,9 +1857,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", - "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", "dev": true, "license": "MIT", "engines": { @@ -1160,15 +1874,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", - "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1185,9 +1899,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", - "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", "dev": true, "license": "MIT", "engines": { @@ -1199,16 +1913,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", - "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.1", - "@typescript-eslint/tsconfig-utils": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1227,9 +1941,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -1240,16 +1954,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", - "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1" + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1264,13 +1978,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", - "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1282,13 +1996,13 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", - "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1307,13 +2021,125 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1331,6 +2157,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -1348,6 +2184,52 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "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": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1359,9 +2241,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.24", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz", - "integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1372,22 +2254,22 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "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==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -1404,12 +2286,11 @@ } ], "license": "MIT", - "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -1420,9 +2301,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001791", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "dev": true, "funding": [ { @@ -1440,6 +2321,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1471,13 +2362,40 @@ "node": ">= 8" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1496,6 +2414,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1522,22 +2447,94 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dompurify": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", - "integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "node_modules/electron-to-chromium": { - "version": "1.5.345", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.345.tgz", - "integrity": "sha512-F9JXQGiMrz6yVNPI2qOVPvB9HzjH5cGzhs8oJ6A28V5L/YnzN/0KsuiibqF+F1Fd9qxFzD1BUnYSd8JfULxTwg==", + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", "dev": true, "license": "ISC" }, + "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": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1562,19 +2559,21 @@ } }, "node_modules/eslint": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", - "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", - "peer": true, + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.5.5", + "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -1596,7 +2595,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -1639,9 +2638,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", - "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1734,6 +2733,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1744,6 +2753,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1828,9 +2847,9 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", "dev": true, "license": "ISC" }, @@ -1879,9 +2898,9 @@ "license": "BSD-2-Clause" }, "node_modules/globals": { - "version": "17.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", - "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", "dev": true, "license": "MIT", "engines": { @@ -1908,6 +2927,60 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "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": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "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": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "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": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1951,6 +3024,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1959,12 +3039,12 @@ "license": "ISC" }, "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz", + "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==", "license": "MIT", "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/js-tokens": { @@ -1974,6 +3054,46 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2046,9 +3166,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -2062,23 +3182,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -2097,9 +3217,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -2118,9 +3238,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -2139,9 +3259,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -2160,9 +3280,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -2181,9 +3301,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -2202,9 +3322,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -2223,9 +3343,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -2244,9 +3364,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -2265,9 +3385,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -2286,9 +3406,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -2333,14 +3453,35 @@ } }, "node_modules/lucide-react": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", - "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.26.0.tgz", + "integrity": "sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/marked": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", @@ -2374,7 +3515,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", - "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -2388,9 +3528,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -2414,12 +3554,36 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2470,6 +3634,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2490,6 +3667,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2498,12 +3682,11 @@ "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==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2512,9 +3695,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -2532,7 +3715,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2550,6 +3733,22 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2586,37 +3785,42 @@ } }, "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.5" + "react": "^19.2.8" } }, - "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, "license": "MIT", + "peer": true + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -2625,30 +3829,50 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" - } - }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "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/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2688,6 +3912,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2698,6 +3929,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", @@ -2723,10 +3961,34 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "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": { @@ -2740,6 +4002,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -2778,7 +4096,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2788,16 +4105,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", - "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.1", - "@typescript-eslint/parser": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/utils": "8.59.1" + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2812,9 +4129,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -2869,18 +4186,16 @@ } }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, - "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", - "tinyglobby": "^0.2.16" + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -2896,7 +4211,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -2947,6 +4262,164 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2963,6 +4436,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -2973,6 +4463,45 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -2994,12 +4523,11 @@ } }, "node_modules/zod": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.1.tgz", - "integrity": "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/package.json b/web/package.json index ae92771..3435786 100644 --- a/web/package.json +++ b/web/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "test": "vitest run", "preview": "vite preview" }, "dependencies": { @@ -17,6 +18,7 @@ "@ruby/wasm-wasi": "^2.9.3-2.9.4", "clsx": "^2.1.1", "lucide-react": "^1.14.0", + "monaco-editor": "0.55.1", "quickjs-emscripten": "^0.32.0", "react": "^19.2.5", "react-dom": "^19.2.5" @@ -26,6 +28,8 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -34,8 +38,10 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", + "jsdom": "^26.1.0", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", - "vite": "^8.0.10" + "vite": "8.0.16", + "vitest": "^4.0.18" } } diff --git a/web/public/robots.txt b/web/public/robots.txt index 38c0771..1fe979e 100644 --- a/web/public/robots.txt +++ b/web/public/robots.txt @@ -1,5 +1,5 @@ # Hafa Code -# https://hafacode.com +# https://hafa-code.netlify.app User-agent: * Allow: / @@ -7,4 +7,4 @@ Disallow: /api/ Disallow: /admin/ Disallow: /rails/ -Sitemap: https://hafacode.com/sitemap.xml +Sitemap: https://hafa-code.netlify.app/sitemap.xml diff --git a/web/public/sitemap.xml b/web/public/sitemap.xml index e6a2849..120cf4f 100644 --- a/web/public/sitemap.xml +++ b/web/public/sitemap.xml @@ -1,7 +1,7 @@ - https://hafacode.com/ + https://hafa-code.netlify.app/ 2026-04-30 weekly 1.0 diff --git a/web/src/App.css b/web/src/App.css index daca128..502b1e2 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -143,6 +143,7 @@ button.icon-button { .app-shell[data-theme="dark"] .invite-row, .app-shell[data-theme="dark"] .title-field input, .app-shell[data-theme="dark"] .file-path-field input, +.app-shell[data-theme="dark"] .file-path-field textarea, .app-shell[data-theme="dark"] .file-path-field select, .app-shell[data-theme="dark"] .member-role-select, .app-shell[data-theme="dark"] .workspace-select, @@ -535,6 +536,46 @@ button.context-chip.active, padding: 1rem; } +.classroom-settings { + border-bottom: 1px solid var(--line); + display: grid; + gap: 0.85rem; + padding: 1rem; +} + +.classroom-settings-actions { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; +} + +.audit-event-list { + border-top: 1px solid var(--line); + display: grid; + gap: 0.45rem; + padding-top: 0.85rem; +} + +.audit-event-row { + align-items: center; + background: rgba(255, 255, 255, 0.42); + border: 1px solid var(--line); + border-radius: 0.75rem; + display: flex; + justify-content: space-between; + padding: 0.6rem 0.7rem; +} + +.audit-event-row span { + font-size: 0.82rem; + font-weight: 800; + text-transform: capitalize; +} + +.audit-event-row small { + color: var(--muted); +} + .invite-form { align-items: end; display: grid; @@ -1586,7 +1627,9 @@ button.context-chip.active, font-weight: 900; } -.file-path-field input { +.file-path-field input, +.file-path-field textarea { + background: rgba(255, 255, 255, 0.72); border: 1px solid var(--line); border-radius: 0.9rem; color: var(--ink); @@ -1595,6 +1638,11 @@ button.context-chip.active, width: 100%; } +.file-path-field textarea { + min-height: 6.5rem; + resize: vertical; +} + .form-error { color: #982517; font-size: 0.82rem; @@ -2281,3 +2329,145 @@ button.context-chip.active, max-height: 220px; } } + +.project-feedback { + margin-bottom: 1rem; + padding: 1rem; +} + +.feedback-header { + align-items: flex-start; +} + +.feedback-count { + align-items: center; + background: color-mix(in srgb, var(--lagoon) 12%, var(--paper)); + border: 1px solid color-mix(in srgb, var(--lagoon) 30%, var(--line)); + border-radius: 999px; + color: var(--lagoon); + display: inline-flex; + font-size: 0.75rem; + font-weight: 800; + min-height: 2rem; + padding: 0.35rem 0.7rem; + white-space: nowrap; +} + +.feedback-list { + display: grid; + gap: 0.75rem; + margin-top: 0.9rem; + max-height: 24rem; + overflow: auto; + padding-right: 0.25rem; +} + +.feedback-comment { + background: color-mix(in srgb, var(--paper) 90%, var(--lagoon) 10%); + border: 1px solid var(--line); + border-left: 3px solid var(--lagoon); + border-radius: 0 0.75rem 0.75rem 0; + padding: 0.85rem; +} + +.feedback-comment.resolved { + border-left-color: var(--muted); + opacity: 0.72; +} + +.feedback-comment > p { + line-height: 1.6; + margin: 0.65rem 0; + white-space: pre-wrap; +} + +.feedback-comment-meta { + align-items: flex-start; + display: flex; + gap: 1rem; + justify-content: space-between; +} + +.feedback-comment-meta > div { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.feedback-comment-meta span, +.feedback-comment-meta time, +.feedback-location { + color: var(--muted); + font-size: 0.75rem; +} + +.feedback-comment-meta span { + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.12rem 0.42rem; + text-transform: capitalize; +} + +.feedback-location { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-weight: 700; +} + +.feedback-form { + border-top: 1px solid var(--line); + display: grid; + gap: 0.75rem; + margin-top: 1rem; + padding-top: 1rem; +} + +.feedback-form label { + display: grid; + gap: 0.4rem; +} + +.feedback-form label > span { + align-items: center; + color: var(--muted); + display: inline-flex; + font-size: 0.78rem; + font-weight: 800; + gap: 0.35rem; +} + +.feedback-form textarea { + min-height: 6.5rem; + resize: vertical; +} + +.feedback-reference-row { + align-items: end; + display: grid; + gap: 0.65rem; + grid-template-columns: minmax(12rem, 1fr) minmax(6rem, 8rem) auto; +} + +.feedback-error { + color: #982517; + font-size: 0.82rem; + font-weight: 700; + margin: 0; +} + +@media (max-width: 720px) { + .feedback-reference-row { + grid-template-columns: 1fr 7rem; + } + + .feedback-reference-row button { + grid-column: 1 / -1; + width: 100%; + } + + .feedback-comment-meta { + align-items: flex-start; + flex-direction: column; + gap: 0.4rem; + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index c3512ff..1c28c9b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import MonacoEditor from '@monaco-editor/react' import { SignInButton, SignUpButton } from '@clerk/clerk-react' import { @@ -46,6 +46,7 @@ import { } from './lib/codeRunner' import { createLocalCheckpoint, + createConflictCopy, createProject, duplicateProject, encodeProjectForShare, @@ -57,11 +58,18 @@ import { type ProjectLibrary, } from './lib/projectStorage' import { useAuthContext } from './contexts/AuthContext' -import { api, type CloudOrgInvitation, type CloudOrgMember } from './lib/api' +import { api, type CloudAuditEvent, type CloudOrgInvitation, type CloudOrgMember } from './lib/api' import { hasClerkPublishableKey } from './lib/clerk' import { AuthControls } from './components/AuthControls' import { RunnerPanel } from './components/RunnerPanel' import { WebPreview } from './components/WebPreview' +import { ProjectFeedback } from './components/ProjectFeedback' +import { + clearProjectPendingCloudSync, + markProjectPendingCloudSync, + pendingCloudProjectIds, + replacePendingCloudProjectId, +} from './lib/cloudSyncStorage' import { COLOR_MODE_STORAGE_KEY, THEME_STORAGE_KEY, @@ -72,6 +80,7 @@ import { type ColorModePreference, type ThemePreference, } from './hooks/usePreferences' +import { useModalFocus } from './hooks/useModalFocus' import { PROJECT_FILE_LIMIT, availableVisibilityOptions, @@ -111,6 +120,10 @@ type ShareDialogState = { error?: string | null } | null +type CloudSaveStatus = 'pending' | 'saving' | 'saved' | 'offline' | 'failed' | 'conflict' + +const CLOUD_SAVE_RETRY_DELAYS = [1_500, 3_000, 6_000] + export default function App() { const initial = useMemo(() => loadInitialLibraryWithSharedProject(), []) const [library, setLibrary] = useState(initial.library) @@ -129,8 +142,10 @@ export default function App() { const [activeOrganizationId, setActiveOrganizationId] = useState(null) const [orgMembers, setOrgMembers] = useState([]) const [orgInvitations, setOrgInvitations] = useState([]) + const [auditEvents, setAuditEvents] = useState([]) const [inviteEmailDraft, setInviteEmailDraft] = useState('') const [inviteRoleDraft, setInviteRoleDraft] = useState('student') + const [schoolYearDraft, setSchoolYearDraft] = useState('') const [lastInviteUrl, setLastInviteUrl] = useState('') const [classroomTab, setClassroomTab] = useState('people') const [memberSearchDraft, setMemberSearchDraft] = useState('') @@ -147,9 +162,14 @@ export default function App() { const [mobileTab, setMobileTab] = useState('home') const [hasImportedServerShare, setHasImportedServerShare] = useState(() => !new URLSearchParams(window.location.hash.replace(/^#/, '')).has('share')) const [hasLoadedCloudProjects, setHasLoadedCloudProjects] = useState(false) + const [cloudSaveStatuses, setCloudSaveStatuses] = useState>({}) const importInputRef = useRef(null) const checkpointMenuRef = useRef(null) - const syncTimerRef = useRef(null) + const syncTimersRef = useRef>(new Map()) + const syncingProjectIdsRef = useRef>(new Set()) + const syncRetryCountsRef = useRef>(new Map()) + const syncedProjectVersionsRef = useRef>(new Map()) + const syncCloudProjectRef = useRef<(projectId: string) => Promise>(async () => {}) const replacingCloudIdRef = useRef(false) const acceptingInvitationTokenRef = useRef(null) const libraryRef = useRef(library) @@ -158,6 +178,17 @@ export default function App() { const cloudEnabled = hasClerkPublishableKey(import.meta.env.VITE_CLERK_PUBLISHABLE_KEY) const editorFontSize = useResponsiveEditorFontSize() const systemDark = useSystemDarkMode() + const fileDialogRef = useModalFocus(Boolean(fileDialog), () => { + setFileDialog(null) + setFileDialogError('') + }) + const shareDialogRef = useModalFocus(Boolean(shareDialog), () => setShareDialog(null)) + const orgDialogRef = useModalFocus(orgCreateOpen, () => setOrgCreateOpen(false)) + const projectActionsDialogRef = useModalFocus(projectActionsOpen, () => setProjectActionsOpen(false)) + const confirmDialogRef = useModalFocus(Boolean(confirmAction), () => { + setConfirmAction(null) + setPendingCheckpoint(null) + }) const project = library.projects.find((candidate) => candidate.id === library.activeProjectId) ?? library.projects[0] const activeFile = project.files.find((file) => file.path === activePath) ?? project.files[0] @@ -174,6 +205,8 @@ export default function App() { name: pendingInvitation.organization.name, slug: pendingInvitation.organization.slug, role: pendingInvitation.role, + school_year: null, + archived_at: null, } : null const activeOrganization = organizations.find((organization) => String(organization.id) === activeOrganizationId) ?? optimisticInvitationOrganization @@ -182,7 +215,8 @@ export default function App() { const canInviteOrgMembers = activeOrganization?.role === 'instructor' || activeOrganization?.role === 'owner' || user?.role === 'admin' const canManageOrgMembers = activeOrganization?.role === 'owner' || user?.role === 'admin' const canCreateOrganization = user?.role === 'admin' || user?.role === 'mentor' - const canEditProject = !isSignedIn || !project.owner || project.owner.id === user?.id + const workspaceArchived = Boolean(activeOrganization?.archived_at) + const canEditProject = (!isSignedIn || !project.owner || project.owner.id === user?.id) && !workspaceArchived const currentProjectOwnerLabel = projectOwnerLabel(project, user?.id) const pendingInvitations = orgInvitations.filter((invitation) => !invitation.accepted_at) const memberSearch = memberSearchDraft.trim().toLowerCase() @@ -192,10 +226,163 @@ export default function App() { .some((value) => value.toLowerCase().includes(memberSearch)) }) const inviteRequiresAuth = Boolean(pendingInvitationToken && pendingInvitation && !isSignedIn) + const currentCloudSaveStatus = cloudSaveStatuses[project.id] + const cloudSaveLabel = currentCloudSaveStatus === 'saving' + ? 'Saving to cloud' + : currentCloudSaveStatus === 'pending' + ? 'Waiting to save' + : currentCloudSaveStatus === 'offline' + ? 'Offline · local copy safe' + : currentCloudSaveStatus === 'failed' + ? 'Cloud save failed · local copy safe' + : currentCloudSaveStatus === 'conflict' + ? 'Save conflict · local copy safe' + : 'Saved to cloud + local backup' + const canAccessProjectFeedback = isSignedIn && isCloudProjectId(project.id) && ( + canEditProject || Boolean(project.organizationId && canUseInstructorPanel) + ) const resolvedTheme = themePreference === 'system' ? (systemDark ? 'dark' : 'light') : themePreference + const updateCloudSaveStatus = useCallback((projectId: string, status: CloudSaveStatus) => { + setCloudSaveStatuses((current) => current[projectId] === status ? current : { ...current, [projectId]: status }) + }, []) + + const scheduleCloudSave = useCallback((projectId: string, delay = 900) => { + const existingTimer = syncTimersRef.current.get(projectId) + if (existingTimer) window.clearTimeout(existingTimer) + + const timer = window.setTimeout(() => { + syncTimersRef.current.delete(projectId) + void syncCloudProjectRef.current(projectId) + }, delay) + syncTimersRef.current.set(projectId, timer) + }, []) + + const syncCloudProject = useCallback(async (projectId: string) => { + if (!isSignedIn || !hasLoadedCloudProjects) return + if (!navigator.onLine) { + updateCloudSaveStatus(projectId, 'offline') + return + } + if (syncingProjectIdsRef.current.has(projectId)) { + scheduleCloudSave(projectId, 150) + return + } + + const projectToSave = libraryRef.current.projects.find((candidate) => candidate.id === projectId) + if (!projectToSave || (projectToSave.owner && projectToSave.owner.id !== user?.id)) return + + syncingProjectIdsRef.current.add(projectId) + updateCloudSaveStatus(projectId, 'saving') + + const res = isCloudProjectId(projectId) + ? await api.updateProject(projectToSave) + : await api.createProject(projectToSave) + + syncingProjectIdsRef.current.delete(projectId) + + if (res.error || !res.data) { + if (res.code === 'project_conflict') { + syncRetryCountsRef.current.delete(projectId) + const serverProject = res.conflictProject + if (serverProject) { + const conflictCopy = createConflictCopy(projectToSave) + const currentLibrary = libraryRef.current + const nextLibrary = { + activeProjectId: currentLibrary.activeProjectId === projectId ? conflictCopy.id : currentLibrary.activeProjectId, + projects: [ + conflictCopy, + ...currentLibrary.projects.map((candidate) => candidate.id === projectId ? serverProject : candidate), + ], + } + libraryRef.current = nextLibrary + setLibrary(nextLibrary) + if (currentLibrary.activeProjectId === projectId) setActivePath(conflictCopy.files[0].path) + clearProjectPendingCloudSync(projectId) + syncedProjectVersionsRef.current.set(serverProject.id, serverProject.updatedAt) + markProjectPendingCloudSync(conflictCopy.id, conflictCopy.updatedAt) + setCloudSaveStatuses((current) => { + const next = { ...current } + delete next[projectId] + next[serverProject.id] = 'saved' + next[conflictCopy.id] = 'pending' + return next + }) + scheduleCloudSave(conflictCopy.id, 0) + setNotice('Another tab saved first. Your version is safe in a new private Conflict Copy, and the latest server version was restored.') + } else { + updateCloudSaveStatus(projectId, 'conflict') + setNotice('Cloud save conflict: your local copy is safe. Export it before reloading.') + } + return + } + + const retryCount = syncRetryCountsRef.current.get(projectId) ?? 0 + const retryDelay = CLOUD_SAVE_RETRY_DELAYS[retryCount] + if (retryDelay) { + syncRetryCountsRef.current.set(projectId, retryCount + 1) + updateCloudSaveStatus(projectId, 'pending') + scheduleCloudSave(projectId, retryDelay) + setNotice(`Cloud save failed: ${res.error || 'unknown error'}. Your local copy is safe; retry ${retryCount + 1} of ${CLOUD_SAVE_RETRY_DELAYS.length} is scheduled.`) + } else { + updateCloudSaveStatus(projectId, 'failed') + setNotice(`Cloud save failed after ${CLOUD_SAVE_RETRY_DELAYS.length} retries: ${res.error || 'unknown error'}. Your local copy is safe; edit again or reconnect to retry.`) + } + return + } + + syncRetryCountsRef.current.delete(projectId) + const savedProject = res.data + const latestProject = libraryRef.current.projects.find((candidate) => candidate.id === projectId) + const changedWhileSaving = Boolean(latestProject && latestProject.updatedAt !== projectToSave.updatedAt) + const projectNeedingAnotherSave: SavedProject | null = latestProject && changedWhileSaving + ? { + ...latestProject, + id: savedProject.id, + owner: savedProject.owner, + organization: savedProject.organization, + organizationId: savedProject.organizationId, + lockVersion: savedProject.lockVersion, + } + : null + const nextProject = projectNeedingAnotherSave ?? savedProject + const currentLibrary = libraryRef.current + const nextLibrary = { + activeProjectId: currentLibrary.activeProjectId === projectId ? nextProject.id : currentLibrary.activeProjectId, + projects: currentLibrary.projects.map((candidate) => candidate.id === projectId ? nextProject : candidate), + } + libraryRef.current = nextLibrary + setLibrary(nextLibrary) + + syncedProjectVersionsRef.current.set(savedProject.id, savedProject.updatedAt) + if (savedProject.id !== projectId) { + clearProjectPendingCloudSync(projectId) + if (changedWhileSaving || projectNeedingAnotherSave) { + replacePendingCloudProjectId(projectId, savedProject.id, (projectNeedingAnotherSave ?? latestProject ?? savedProject).updatedAt) + } + setCloudSaveStatuses((current) => { + const next = { ...current } + delete next[projectId] + next[savedProject.id] = projectNeedingAnotherSave ? 'pending' : 'saved' + return next + }) + } + + if (projectNeedingAnotherSave) { + markProjectPendingCloudSync(projectNeedingAnotherSave.id, projectNeedingAnotherSave.updatedAt) + updateCloudSaveStatus(projectNeedingAnotherSave.id, 'pending') + scheduleCloudSave(projectNeedingAnotherSave.id, 0) + } else { + clearProjectPendingCloudSync(savedProject.id) + updateCloudSaveStatus(savedProject.id, 'saved') + } + }, [hasLoadedCloudProjects, isSignedIn, scheduleCloudSave, updateCloudSaveStatus, user?.id]) + useEffect(() => { + syncCloudProjectRef.current = syncCloudProject + }, [syncCloudProject]) + const activateProject = (nextProject: SavedProject) => { setLibrary((current) => ({ ...current, activeProjectId: nextProject.id })) setActivePath(nextProject.files[0].path) @@ -372,9 +559,13 @@ export default function App() { return } if (res.data && res.data.length > 0) { - const remainingContextProjects = libraryRef.current.projects.filter((candidate) => !projectContextMatches(candidate, activeOrganizationId)) - const baseLibrary = { ...libraryRef.current, projects: remainingContextProjects } - const merged = mergeCloudAndLocalProjects(res.data, baseLibrary, activeOrganizationId) + const pendingIds = pendingCloudProjectIds() + res.data.forEach((cloudProject) => { + if (!pendingIds.has(cloudProject.id)) { + syncedProjectVersionsRef.current.set(cloudProject.id, cloudProject.updatedAt) + } + }) + const merged = mergeCloudAndLocalProjects(res.data, libraryRef.current, activeOrganizationId) const nextProject = merged.projects.find((candidate) => candidate.id === merged.activeProjectId) ?? merged.projects[0] setLibrary(merged) setActivePath(nextProject.files[0].path) @@ -404,33 +595,40 @@ export default function App() { useEffect(() => { if (!isSignedIn || !hasLoadedCloudProjects || replacingCloudIdRef.current || !canEditProject) return - if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current) - - syncTimerRef.current = window.setTimeout(async () => { - if (isCloudProjectId(project.id)) { - const res = await api.updateProject(project) - if (res.error) setNotice(`Cloud save failed: ${res.error}`) - return - } + if (syncedProjectVersionsRef.current.get(project.id) === project.updatedAt) { + updateCloudSaveStatus(project.id, 'saved') + return + } - const res = await api.createProject(project) - if (res.error || !res.data) { - setNotice(`Cloud save failed: ${res.error || 'unknown error'}`) - return - } + markProjectPendingCloudSync(project.id, project.updatedAt) + syncRetryCountsRef.current.delete(project.id) + updateCloudSaveStatus(project.id, 'pending') + scheduleCloudSave(project.id) + }, [canEditProject, hasLoadedCloudProjects, isSignedIn, project, scheduleCloudSave, updateCloudSaveStatus]) - replacingCloudIdRef.current = true - setLibrary((current) => ({ - activeProjectId: current.activeProjectId === project.id ? res.data!.id : current.activeProjectId, - projects: current.projects.map((candidate) => candidate.id === project.id ? res.data! : candidate), - })) - window.setTimeout(() => { replacingCloudIdRef.current = false }, 0) - }, 900) + useEffect(() => { + const syncTimers = syncTimersRef.current + const flushPendingProjects = () => { + pendingCloudProjectIds().forEach((projectId) => { + if (libraryRef.current.projects.some((candidate) => candidate.id === projectId)) { + void syncCloudProject(projectId) + } + }) + } + const retryPendingProjects = () => { + syncRetryCountsRef.current.clear() + flushPendingProjects() + } + window.addEventListener('pagehide', flushPendingProjects) + window.addEventListener('online', retryPendingProjects) return () => { - if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current) + window.removeEventListener('pagehide', flushPendingProjects) + window.removeEventListener('online', retryPendingProjects) + syncTimers.forEach((timer) => window.clearTimeout(timer)) + syncTimers.clear() } - }, [canEditProject, hasLoadedCloudProjects, isSignedIn, library, project]) + }, [syncCloudProject]) const setActiveProject = (projectId: string) => { const nextProject = library.projects.find((candidate) => candidate.id === projectId) @@ -440,6 +638,10 @@ export default function App() { } const addProject = (kind: ProjectKind) => { + if (workspaceArchived) { + setNotice('This classroom is archived and read-only.') + return + } const starter = createProject(kind) const next = { ...starter, @@ -483,31 +685,95 @@ export default function App() { setNotice(`${res.data.name} created.`) } + const saveOrganizationSettings = async () => { + if (!activeOrganizationId || !activeOrganization) return + const res = await api.updateOrganization(activeOrganizationId, { school_year: schoolYearDraft.trim() }) + if (res.error) { + setNotice(`Could not update classroom settings: ${res.error}`) + return + } + await syncSession() + setNotice('Classroom settings updated.') + } + + const toggleOrganizationArchive = async () => { + if (!activeOrganizationId || !activeOrganization) return + const action = activeOrganization.archived_at ? 'restore' : 'archive' + if (!window.confirm(`${action === 'archive' ? 'Archive' : 'Restore'} ${activeOrganization.name}? ${action === 'archive' ? 'Students can still view work, but source changes and new invitations will be disabled.' : 'Source editing and invitations will be enabled again.'}`)) return + + const res = activeOrganization.archived_at + ? await api.unarchiveOrganization(activeOrganizationId) + : await api.archiveOrganization(activeOrganizationId) + if (res.error) { + setNotice(`Could not ${action} classroom: ${res.error}`) + return + } + await syncSession() + setNotice(`${activeOrganization.name} ${action === 'archive' ? 'archived' : 'restored'}.`) + } + + const exportOrganization = async () => { + if (!activeOrganizationId || !activeOrganization) return + const res = await api.exportOrganization(activeOrganizationId) + if (res.error || !res.data) { + setNotice(`Could not export classroom: ${res.error || 'unknown error'}`) + return + } + + const blob = new Blob([JSON.stringify(res.data, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `${activeOrganization.slug}-${activeOrganization.school_year || 'classroom'}-export.json` + document.body.append(link) + link.click() + link.remove() + URL.revokeObjectURL(url) + setNotice('Classroom export downloaded.') + } + const inviteOrgMember = async (event: React.FormEvent) => { event.preventDefault() if (!activeOrganizationId) return - const email = inviteEmailDraft.trim().toLowerCase() - if (!email) { - setNotice('Enter an email address to invite.') + const emails = inviteEmailDraft.split(/[\s,;]+/) + .map((email) => email.trim().toLowerCase()) + .filter(Boolean) + .filter((email, index, values) => values.indexOf(email) === index) + if (emails.length === 0) { + setNotice('Enter at least one email address to invite.') return } const role = canManageOrgMembers ? inviteRoleDraft : 'student' - const res = await api.createOrgInvitation(activeOrganizationId, email, role) - if (res.error || !res.data) { - setNotice(`Could not create invitation: ${res.error || 'unknown error'}`) + const res = emails.length === 1 + ? await api.createOrgInvitation(activeOrganizationId, emails[0], role).then((single) => ({ + data: single.data ? { invitations: [single.data], errors: [] } : null, + error: single.error, + })) + : await api.createOrgInvitations(activeOrganizationId, emails, role) + if (res.error || !res.data || res.data.invitations.length === 0) { + setNotice(`Could not create invitation${emails.length === 1 ? '' : 's'}: ${res.error || res.data?.errors.map((error) => `${error.email}: ${error.errors.join(', ')}`).join('; ') || 'unknown error'}`) return } - const url = res.data.invitation_url || invitationUrl(res.data.token) - setOrgInvitations((current) => [res.data!, ...current.filter((candidate) => candidate.id !== res.data!.id)]) + const createdInvitations = res.data.invitations + const latestInvitation = createdInvitations[0] + const url = latestInvitation.invitation_url || invitationUrl(latestInvitation.token) + setOrgInvitations((current) => [ + ...createdInvitations, + ...current.filter((candidate) => !createdInvitations.some((created) => created.id === candidate.id)), + ]) setInviteEmailDraft('') setInviteRoleDraft('student') setLastInviteUrl(url) const copied = await writeClipboardText(url) - if (res.data.email_sent) { - setNotice(copied ? 'Invitation email sent. Backup link copied.' : 'Invitation email sent. Backup link is in the classroom panel.') + const failedCount = res.data.errors.length + const queuedCount = createdInvitations.filter((invitation) => invitation.email_queued).length + if (createdInvitations.length > 1) { + setNotice(`${createdInvitations.length} invitation${createdInvitations.length === 1 ? '' : 's'} created${queuedCount ? `; ${queuedCount} email${queuedCount === 1 ? '' : 's'} queued` : ''}${failedCount ? `; ${failedCount} failed` : ''}.`) + } else if (latestInvitation.email_queued) { + setNotice(copied ? 'Invitation email queued. Backup link copied.' : 'Invitation email queued. Backup link is in the classroom panel.') } else { setNotice(copied ? 'Invitation created. Email is not configured yet, so the link was copied.' : 'Invitation created. Email is not configured yet, so copy the link from the classroom panel.') } @@ -533,12 +799,12 @@ export default function App() { const url = res.data.invitation_url || invitationUrl(res.data.token) setOrgInvitations((current) => current.map((candidate) => candidate.id === res.data!.id ? res.data! : candidate)) setLastInviteUrl(url) - setNotice(res.data.email_sent ? 'Invitation email resent.' : 'Invitation resent link is ready, but email is not configured yet.') + setNotice(res.data.email_queued ? 'Invitation email queued to resend.' : 'Invitation link renewed, but email is not configured yet.') } const revokeInvitation = async (invitation: CloudOrgInvitation) => { if (!activeOrganizationId || !invitation.id) return - if (!window.confirm(`Revoke the invitation for ${invitation.email}? The current link will stop working.`)) return + if (!window.confirm(`Revoke the invitation for ${invitation.email || 'this account'}? The current link will stop working.`)) return const res = await api.deleteOrgInvitation(activeOrganizationId, invitation.id) if (res.error) { @@ -566,7 +832,7 @@ export default function App() { const removeOrgMember = async (member: CloudOrgMember) => { if (!activeOrganizationId) return - if (!window.confirm(`Remove ${member.full_name} from ${activeOrganization?.name || 'this organization'}? Their projects stay in the workspace, but they lose organization access.`)) return + if (!window.confirm(`Remove ${member.full_name} from ${activeOrganization?.name || 'this organization'}? Their class projects will move to their private Personal workspace so their work is not stranded.`)) return const res = await api.deleteOrgMember(activeOrganizationId, member.membership_id) if (res.error) { @@ -619,17 +885,24 @@ export default function App() { const flushCloudProject = async (projectToFlush: SavedProject) => { if (!isSignedIn || !isCloudProjectId(projectToFlush.id)) return projectToFlush - if (syncTimerRef.current) { - window.clearTimeout(syncTimerRef.current) - syncTimerRef.current = null + const pendingTimer = syncTimersRef.current.get(projectToFlush.id) + if (pendingTimer) { + window.clearTimeout(pendingTimer) + syncTimersRef.current.delete(projectToFlush.id) } const res = await api.updateProject(projectToFlush) if (res.error || !res.data) { - setNotice(`Cloud save failed: ${res.error || 'unknown error'}`) + updateCloudSaveStatus(projectToFlush.id, res.code === 'project_conflict' ? 'conflict' : 'failed') + setNotice(res.code === 'project_conflict' + ? 'Cloud save conflict: this project changed in another tab. Export your local copy before reloading.' + : `Cloud save failed: ${res.error || 'unknown error'}. Your local copy is still safe.`) return null } + clearProjectPendingCloudSync(projectToFlush.id) + syncedProjectVersionsRef.current.set(res.data.id, res.data.updatedAt) + updateCloudSaveStatus(res.data.id, 'saved') setLibrary((current) => ({ ...current, projects: current.projects.map((candidate) => candidate.id === res.data!.id ? res.data! : candidate), @@ -700,6 +973,10 @@ export default function App() { } const cloneProject = () => { + if (workspaceArchived) { + setNotice('Restore this classroom before duplicating projects into it.') + return + } setProjectActionsOpen(false) const copy = duplicateProject(project) setLibrary((current) => ({ activeProjectId: copy.id, projects: [copy, ...current.projects] })) @@ -950,6 +1227,11 @@ export default function App() { } const copyShareLink = async () => { + if (project.organizationId) { + setNotice('External snapshot sharing is disabled for classroom projects. Use Teacher only or Class visibility instead.') + return + } + const share = await api.createShare(project) const url = share.data ? `${window.location.origin}${window.location.pathname}#share=${share.data.token}` @@ -1015,7 +1297,13 @@ export default function App() { - + handleImportFile(event.target.files?.[0])} />
@@ -1027,7 +1315,13 @@ export default function App() { - +
@@ -1072,7 +1366,7 @@ export default function App() {

{activeOrganization ? activeOrganization.name : 'Personal projects'}

{activeOrganization - ? `${activeOrganization.role} workspace. Private projects are still visible to instructors.` + ? `${activeOrganization.role} workspace${activeOrganization.school_year ? ` · ${activeOrganization.school_year}` : ''}${workspaceArchived ? ' · archived and read-only' : ''}. Teacher-only projects are visible to instructors.` : 'Your own projects, separate from any classroom or organization.'}

@@ -1090,7 +1384,7 @@ export default function App() { {organizations.map((organization) => ( ))} @@ -1164,30 +1458,97 @@ export default function App() { > Invitations + {canManageOrgMembers && ( + + )} + {workspaceArchived && ( +

+ This classroom is archived. Projects, roster changes, invitations, and settings are read-only until an owner restores it. +

+ )} + {classroomTab === 'settings' && canManageOrgMembers && ( +
+ +
+ + + +
+

Exports include the roster, source files, and feedback. Archived classrooms stay available for review and export, but project source and invitations become read-only.

+
+
+ Recent activity + {auditEvents.length} event{auditEvents.length === 1 ? '' : 's'} +
+ {auditEvents.slice(0, 12).map((event) => ( +
+ {event.action.replaceAll('.', ' ')} + {event.actor?.full_name || 'System'} · {formatUpdatedAt(event.created_at)} +
+ ))} + {auditEvents.length === 0 &&

No classroom activity has been recorded yet.

} +
+
+ )} {classroomTab === 'invitations' && canInviteOrgMembers && (