From dc1e4f7555111454876785594a9faed793246885 Mon Sep 17 00:00:00 2001 From: Aarav261 Date: Sun, 28 Jun 2026 14:37:40 +1000 Subject: [PATCH] chore: make repository open-source contribution ready Relicense and add the community scaffolding contributors expect. License & docs - Relicense from proprietary "all rights reserved" to MIT - README: replace "not open source" section with Contributing + MIT - Add CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md - Un-ignore docker-compose.yml so contributors can run the stack (ARCHITECTURE.md stays private) GitHub scaffolding - Issue templates (bug / feature) + config, PR template - CI workflow: ruff check + pytest (backend), eslint (extension) Make CI green - Fix all 25 ruff lint errors (import sorting, lambda->def, type annotation, wrap long HTML-template lines) - Remove dead, never-defined get_last_seen_token() call in scripts/test_send_now.py (would have raised NameError) - Declare missing eslint devDependencies in extension/ so `npm run lint` works on a fresh clone - Scope pytest to tests/ (pytest.ini) so dev CLI scripts named test_*.py aren't collected - Fix brittle limiter mock so the link-ontrack test builds the app --- .github/ISSUE_TEMPLATE/bug_report.md | 37 + .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.md | 27 + .github/PULL_REQUEST_TEMPLATE.md | 23 + .github/workflows/ci.yml | 53 ++ .gitignore | 3 +- CODE_OF_CONDUCT.md | 61 ++ CONTRIBUTING.md | 129 +++ LICENSE | 37 +- README.md | 12 +- SECURITY.md | 42 + app.py | 2 +- core/brief/builder.py | 4 +- core/brief/renderer.py | 22 +- core/db.py | 3 +- core/email_theme.py | 18 +- core/ontrack/auth.py | 2 +- docker-compose.yml | 33 + extension/package-lock.json | 1047 +++++++++++++++++++-- extension/package.json | 5 + extensions.py | 6 +- pytest.ini | 4 + scripts/test_send_now.py | 9 +- scripts/test_token_lifetime.py | 3 +- tests/__init__.py | 0 tests/test_refresh_token_expiry.py | 361 +++++++ 26 files changed, 1809 insertions(+), 142 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/ci.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 docker-compose.yml create mode 100644 pytest.ini create mode 100644 tests/__init__.py create mode 100644 tests/test_refresh_token_expiry.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..d07381f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,37 @@ +--- +name: Bug report +about: Report something that isn't working as expected +title: "[Bug]: " +labels: bug +assignees: '' +--- + +## Describe the bug +A clear and concise description of what the bug is. + +## To reproduce +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '...' +3. See error + +## Expected behavior +What you expected to happen instead. + +## Component +- [ ] Backend (API / scheduler) +- [ ] Web app (`web/`) +- [ ] Chrome extension (`extension/`) +- [ ] Email brief +- [ ] Other / not sure + +## Environment +- OS: [e.g. Windows 11, macOS 14] +- Browser + version (if extension/web): [e.g. Chrome 126] +- Commit / version: [e.g. `abe83a0` or v1.9] + +## Logs / screenshots +Paste any relevant logs or screenshots. **Redact tokens, emails, and secrets.** + +## Additional context +Anything else that might help. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..5e55806 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/Aarav261/Ontracker/security/advisories/new + about: Please report security issues privately — do not open a public issue. + - name: Questions & discussion + url: https://github.com/Aarav261/Ontracker/discussions + about: Ask questions or discuss ideas here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..5334213 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,27 @@ +--- +name: Feature request +about: Suggest an idea or improvement +title: "[Feature]: " +labels: enhancement +assignees: '' +--- + +## Problem / motivation +What problem does this solve? Is it related to a frustration you have? E.g. +"I'm always frustrated when..." + +## Proposed solution +A clear and concise description of what you want to happen. + +## Alternatives considered +Any alternative solutions or features you've considered. + +## Component +- [ ] Backend (API / scheduler) +- [ ] Web app (`web/`) +- [ ] Chrome extension (`extension/`) +- [ ] Email brief +- [ ] Other + +## Additional context +Mockups, links, or any other context. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..6dffb0f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ +## Summary + + +## Related issue + + +## Type of change +- [ ] Bug fix +- [ ] New feature +- [ ] Refactor / cleanup +- [ ] Documentation +- [ ] Other: + +## Checklist +- [ ] My branch is based on the latest `main` +- [ ] `ruff check .` passes +- [ ] `pytest` passes (if backend changes) +- [ ] `npm run lint` passes in `extension/` (if extension changes) +- [ ] I added/updated tests where it makes sense +- [ ] I did not commit secrets, `.env` files, tokens, or database files + +## Notes for reviewers + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1839e63 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + backend: + name: Backend (ruff + pytest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest + + - name: Ruff lint + run: ruff check . + + - name: Run tests + run: pytest -q + + extension: + name: Extension (lint) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: extension/package-lock.json + + - name: Install dependencies + working-directory: extension + run: npm install + + - name: Lint + working-directory: extension + run: npm run lint diff --git a/.gitignore b/.gitignore index d0243bb..621dfb3 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,8 @@ venv/ .ruff_cache/ .pytest_cache/ *.egg-info/ -# Kept out of the public repo (design doc + local-dev compose) +# Kept out of the public repo (internal design doc) ARCHITECTURE.md -docker-compose.yml # Node / extension node_modules/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..89b2ac2 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,61 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes +* Focusing on what is best for the overall community + +Examples of unacceptable behavior: + +* The use of sexualized language or imagery, and sexual attention or advances +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards and +will take appropriate and fair corrective action in response to any behavior +they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement via GitHub at +[@Aarav261](https://github.com/Aarav261). All complaints will be reviewed and +investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8a861c9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,129 @@ +# Contributing to OnTrack(er) + +Thanks for your interest in contributing! OnTrack(er) sends students a prioritised +weekday morning email brief of their [OnTrack](https://github.com/doubtfire-lms/doubtfire-web) +tasks, with a companion Chrome extension. This guide explains how to get set up and +submit changes. + +By contributing, you agree that your contributions are licensed under the +[MIT License](LICENSE). + +--- + +## Ways to contribute + +- **Report a bug** — open a [bug report](https://github.com/Aarav261/Ontracker/issues/new?template=bug_report.md). +- **Suggest a feature** — open a [feature request](https://github.com/Aarav261/Ontracker/issues/new?template=feature_request.md). +- **Submit code** — fix a bug, add a feature, improve docs. See the workflow below. +- **Report a security issue** — please **do not** open a public issue; see [SECURITY.md](SECURITY.md). + +If you're planning a non-trivial change, please open an issue first to discuss it so +we don't duplicate effort. + +--- + +## Project layout + +| Area | Path | Stack | +|------|------|-------| +| Backend (API + scheduler) | `app.py`, `core/`, `routes/` | Flask · APScheduler · SQLAlchemy | +| Web app (sign-in host) | `web/` | React 18 + Vite | +| Chrome extension (MV3) | `extension/` | React 18 + Vite | +| Tests | `tests/` | pytest | + +--- + +## Local development setup + +### Prerequisites +- Python 3.11+ +- Node.js 18+ +- Docker (for the backend + Postgres), or SQLite for a lighter setup + +### 1. Clone and configure +```bash +git clone https://github.com/Aarav261/Ontracker.git +cd Ontracker +cp .env.example .env.dev # then fill in the values +``` +See [`.env.example`](.env.example) for every variable. You'll need your own +Clerk, Resend, and (optionally) Postgres credentials — none are provided. + +### 2. Backend +```bash +# With Docker (backend + Postgres, reads .env.dev) +docker compose up -d --build + +# Or run directly against SQLite +python -m venv venv && source venv/bin/activate # Windows: venv\Scripts\activate +pip install -r requirements.txt +python app.py +``` + +### 3. Web app +```bash +cd web && npm install && npm run dev # http://localhost:5173 +``` + +### 4. Extension +```bash +cd extension && npm install && npm run build # dev build -> extension/dist +``` +Load `extension/dist` via **chrome://extensions → Load unpacked**. + +--- + +## Code style & checks + +Run these locally before opening a PR — CI runs the same checks. + +```bash +# Python: lint (ruff) — enforced in CI +ruff check . + +# Python tests +pip install pytest +pytest + +# Extension: lint +cd extension && npm run lint +``` + +- Python: line length 100, target py311. Config in [`ruff.toml`](ruff.toml). +- `ruff format .` is available if you'd like autoformatting, but it isn't enforced — + match the style of the surrounding code and keep changes focused. + +--- + +## Pull request workflow + +1. **Fork** the repo and create a branch off `main`: + `git checkout -b fix/short-description` +2. Make your change, with tests where it makes sense. +3. Ensure `ruff check .`, `pytest`, and `npm run lint` (in `extension/`) all pass. +4. Commit with a clear message and open a PR against `main`, filling in the + PR template. +5. A maintainer will review. Please be responsive to feedback. + +Keep PRs small and single-purpose where possible — they're far easier to review +and merge. + +--- + +## Commit messages + +Use clear, imperative subject lines, optionally with a type prefix you'll see in +the history, e.g.: + +``` +feat(extension): capture rotated token from response headers +fix(brief): don't skip users with no captured tasks on cold start +docs: clarify local setup in CONTRIBUTING +``` + +--- + +## Questions + +Open a [discussion or issue](https://github.com/Aarav261/Ontracker/issues) — happy +to help you get started. diff --git a/LICENSE b/LICENSE index cb13a3a..af3f2e8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,28 +1,21 @@ -OnTrack(er) — Proprietary License -Copyright (c) 2026 Aarav (github.com/Aarav261). All Rights Reserved. +MIT License -This source code is made publicly viewable for reference, evaluation, and -portfolio purposes ONLY. Viewing this repository does NOT grant any license to -use it. +Copyright (c) 2026 Aarav (github.com/Aarav261) -You MAY: - - View and read the source code. - - Reference it for learning or evaluation. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -You MAY NOT, without the prior written permission of the copyright holder: - - Use, run, host, or deploy this software or any service based on it. - - Copy, reproduce, or republish the source code, in whole or in part. - - Modify, adapt, translate, or create derivative or competing works. - - Distribute, sublicense, sell, or otherwise commercialise it. - - Remove or alter this license or any copyright notices. - -The name "OnTrack(er)", the associated logo, and the domain on-tracker.com are -the property of the copyright holder and may not be used without permission. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHOR BE -LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM THE USE OF THIS +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -For licensing or commercial enquiries, contact the copyright holder via GitHub. diff --git a/README.md b/README.md index 5537a4c..5ce3cdc 100644 --- a/README.md +++ b/README.md @@ -119,9 +119,13 @@ scripts/ packaging + maintenance helpers --- +## Contributing + +Contributions are welcome! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for how to set +up locally and submit a pull request, and our [Code of Conduct](CODE_OF_CONDUCT.md). +To report a security issue, see [`SECURITY.md`](SECURITY.md). + ## License -© 2026 Aarav. **All rights reserved.** This source is published for reference and -portfolio purposes only — it is **not** open source. You may view it, but you may -not use, copy, deploy, modify, or distribute it without written permission. See -[`LICENSE`](LICENSE) for the full terms. +Released under the [MIT License](LICENSE) © 2026 Aarav. You're free to use, modify, +and distribute it with attribution. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..64fe61f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,42 @@ +# Security Policy + +OnTrack(er) handles authentication tokens and student data, so we take security +seriously. Thank you for helping keep it and its users safe. + +## Reporting a vulnerability + +**Please do not report security vulnerabilities through public GitHub issues, +discussions, or pull requests.** + +Instead, report privately using one of: + +- **GitHub Security Advisories** — preferred: open a draft advisory at + [Security → Report a vulnerability](https://github.com/Aarav261/Ontracker/security/advisories/new). +- **Direct contact** — message the maintainer ([@Aarav261](https://github.com/Aarav261)) on GitHub. + +Please include: +- A description of the vulnerability and its impact. +- Steps to reproduce (proof-of-concept if possible). +- Affected component (backend API, web app, extension) and version/commit. + +## What to expect + +- We aim to acknowledge your report within **72 hours**. +- We'll keep you updated as we investigate and work on a fix. +- We'll credit you in the advisory once it's resolved, unless you prefer to + remain anonymous. + +## Scope + +Particularly sensitive areas: +- OnTrack token capture, storage, and encryption (`core/crypto.py`, `core/ontrack/`). +- Clerk session verification (`core/clerk_auth.py`). +- Any endpoint in `routes/main.py` handling credentials or user data. + +## Please avoid + +- Accessing, modifying, or exfiltrating data that isn't yours. +- Denial-of-service testing against production (`on-tracker.com`). +- Social engineering of users or maintainers. + +Responsible testing against your **own** local instance is encouraged. diff --git a/app.py b/app.py index 63989ed..df485e9 100644 --- a/app.py +++ b/app.py @@ -6,9 +6,9 @@ import os import secrets +import sentry_sdk from flask import Flask from flask_cors import CORS -import sentry_sdk from core.jobs import startup from extensions import limiter diff --git a/core/brief/builder.py b/core/brief/builder.py index 7ba2ab0..bb64183 100644 --- a/core/brief/builder.py +++ b/core/brief/builder.py @@ -134,7 +134,9 @@ def _build_brief( elif status in SUBMITTED: submitted.append((task, unit_code, None)) - sort_key = lambda e: _score(e[0], today) + def sort_key(e): + return _score(e[0], today) + urgent.sort(key=sort_key) todo.sort(key=sort_key) waiting.sort(key=sort_key) diff --git a/core/brief/renderer.py b/core/brief/renderer.py index 5640bcc..5a03e27 100644 --- a/core/brief/renderer.py +++ b/core/brief/renderer.py @@ -63,7 +63,11 @@ def _task_row(entry: dict, today: date) -> str: abbrev = _escape(task.get("abbreviation", "")) if task.get("_url"): - name_cell = f'{name}' + name_cell = ( + f'{name}' + ) else: name_cell = f'{name}' @@ -74,7 +78,8 @@ def _task_row(entry: dict, today: date) -> str: f'color:#666666;white-space:nowrap;border-bottom:1px solid #e8e8e8">{unit_code}' f'{abbrev}' - f'' + f'' f"{name_cell}" f'' @@ -114,14 +119,19 @@ def render_html( ) else: rows = "".join(_task_row(entry, today) for entry in pending_entries) + # Shared style; only the padding differs per column. + _th_base = ( + "font-size:10px;text-transform:uppercase;color:#888888;" + "letter-spacing:.9px;text-align:left;border-bottom:2px solid #e8e8e8" + ) body = f""" - - - - + + + + {rows} diff --git a/core/db.py b/core/db.py index 7feda58..3f4095c 100644 --- a/core/db.py +++ b/core/db.py @@ -9,7 +9,8 @@ from datetime import datetime from pathlib import Path -from core.crypto import decrypt as _decrypt, encrypt as _encrypt +from core.crypto import decrypt as _decrypt +from core.crypto import encrypt as _encrypt log = logging.getLogger(__name__) diff --git a/core/email_theme.py b/core/email_theme.py index 2729d2a..4a8f827 100644 --- a/core/email_theme.py +++ b/core/email_theme.py @@ -25,14 +25,17 @@ def _quote_footer() -> str: text = _escape(text) author = _escape(author) return f""" - diff --git a/core/ontrack/auth.py b/core/ontrack/auth.py index 06d2728..7eccfdf 100644 --- a/core/ontrack/auth.py +++ b/core/ontrack/auth.py @@ -206,7 +206,7 @@ def __init__( @classmethod def for_user( cls, user: dict, *, session: requests.Session | None = None - ) -> "TokenManager": + ) -> TokenManager: """Build a TokenManager from a DB user row.""" return cls( user["base_url"], user["username"], user["auth_token"], session=session diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..143c9bf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +version: "3.9" +services: + web: + build: . + ports: + - "8000:8000" + env_file: + - .env.dev + volumes: + - .:/app + depends_on: + db: + condition: service_healthy + command: gunicorn --bind 0.0.0.0:8000 --workers 1 --reload app:app + + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: ontrackadmin + POSTGRES_PASSWORD: localdev123 + POSTGRES_DB: ontrackbrief_dev + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ontrackadmin -d ontrackbrief_dev"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pgdata: diff --git a/extension/package-lock.json b/extension/package-lock.json index cfb9552..5e75535 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -1,20 +1,25 @@ { "name": "ontracker", - "version": "1.1.0", + "version": "1.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ontracker", - "version": "1.1.0", + "version": "1.11.0", "dependencies": { "@clerk/chrome-extension": "^2.0.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { + "@eslint/js": "^9.9.0", "@vitejs/plugin-react": "^4.3.1", "dotenv": "^16.4.5", + "eslint": "^9.9.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.9", + "globals": "^15.9.0", "vite": "^5.4.0" } }, @@ -982,6 +987,163 @@ "node": ">=12" } }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -1041,6 +1203,72 @@ "integrity": "sha512-DHHC01EJ1p70Q0z/ZFRBIY8NDnmfKccQoyoM84Tgb6omLMat6jivCdf272Y8k3nf4Lzdin/Y4R9q8uFtU0GbnA==", "license": "MIT" }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@isaacs/ttlcache": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", @@ -5257,6 +5485,13 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.9.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", @@ -5482,7 +5717,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5490,6 +5724,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "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", @@ -5513,6 +5757,23 @@ "node": ">= 8.0.0" } }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/alien-signals": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-2.0.6.tgz", @@ -5550,6 +5811,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -5582,6 +5850,13 @@ "hermes-parser": "0.36.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/base-x": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", @@ -5661,6 +5936,17 @@ "base-x": "^3.0.2" } }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -5921,6 +6207,13 @@ "node": ">=20" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/connect": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", @@ -6001,7 +6294,6 @@ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", - "peer": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -6049,6 +6341,13 @@ "node": ">=0.10.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/delay": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", @@ -6252,89 +6551,395 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, "engines": { - "node": ">= 0.6" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "license": "MIT" - }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "license": "Apache-2.0", - "peer": true - }, - "node_modules/eyes": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", - "integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==", - "peer": true, "engines": { - "node": "> 0.1.90" + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, - "node_modules/fast-stable-stringify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz", - "integrity": "sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==", - "license": "MIT", - "peer": true - }, - "node_modules/fastest-levenshtein": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", - "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 4.9.1" + "peerDependencies": { + "eslint": ">=8.40" } }, - "node_modules/fb-dotslash": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", - "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", - "license": "(MIT OR Apache-2.0)", - "peer": true, - "bin": { - "dotslash": "bin/dotslash" + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" }, "engines": { - "node": ">=20" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, - "dependencies": { - "bser": "2.1.1" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/fill-range": { - "version": "7.1.1", + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/eyes": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", + "integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==", + "peer": true, + "engines": { + "node": "> 0.1.90" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-stable-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz", + "integrity": "sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==", + "license": "MIT", + "peer": true + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fb-dotslash": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", + "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", + "license": "(MIT OR Apache-2.0)", + "peer": true, + "bin": { + "dotslash": "bin/dotslash" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "license": "MIT", @@ -6401,6 +7006,27 @@ "node": ">=8" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/flow-enums-runtime": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", @@ -6460,12 +7086,38 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "license": "BSD-2-Clause" }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -6478,7 +7130,6 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -6610,6 +7261,16 @@ "license": "BSD-3-Clause", "peer": true }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/image-size": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", @@ -6642,6 +7303,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -6706,6 +7377,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -6715,6 +7396,19 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6752,8 +7446,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/isomorphic-ws": { "version": "4.0.1", @@ -6987,6 +7680,29 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsc-safe-url": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", @@ -7006,12 +7722,33 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -7031,6 +7768,16 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -7041,6 +7788,20 @@ "node": ">=6" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lighthouse-logger": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", @@ -7093,6 +7854,13 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.throttle": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", @@ -7623,6 +8391,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -7661,6 +8442,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -7771,6 +8559,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ox": { "version": "0.6.9", "resolved": "https://registry.npmjs.org/ox/-/ox-0.6.9.tgz", @@ -7902,7 +8708,6 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -7989,6 +8794,16 @@ "url": "https://opencollective.com/preact" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -8034,6 +8849,16 @@ "asap": "~2.0.6" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -8481,7 +9306,6 @@ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", - "peer": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -8494,7 +9318,6 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -8631,6 +9454,19 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -8830,6 +9666,19 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", @@ -8901,6 +9750,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -8910,20 +9769,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "extraneous": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -9187,7 +10032,6 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", - "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -9204,6 +10048,16 @@ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "license": "ISC" }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -9296,6 +10150,19 @@ "node": ">=6" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zustand": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz", diff --git a/extension/package.json b/extension/package.json index 8ab88d6..e92785f 100644 --- a/extension/package.json +++ b/extension/package.json @@ -16,8 +16,13 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@eslint/js": "^9.9.0", "@vitejs/plugin-react": "^4.3.1", "dotenv": "^16.4.5", + "eslint": "^9.9.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.9", + "globals": "^15.9.0", "vite": "^5.4.0" } } diff --git a/extensions.py b/extensions.py index 78428c6..ad5404c 100644 --- a/extensions.py +++ b/extensions.py @@ -1,8 +1,10 @@ import os + +from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore +from apscheduler.schedulers.background import BackgroundScheduler from flask_limiter import Limiter from flask_limiter.util import get_remote_address -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore + from core.db import get_sqlalchemy_url _REDIS_URL = os.environ.get("REDIS_URL", "") diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..66c7381 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +# Only the tests/ directory holds real pytest suites. The scripts/test_*.py +# files are standalone dev CLI tools, not pytest modules — keep them out. +testpaths = tests diff --git a/scripts/test_send_now.py b/scripts/test_send_now.py index 9da31d4..be3bcbf 100644 --- a/scripts/test_send_now.py +++ b/scripts/test_send_now.py @@ -20,7 +20,7 @@ from core.constants import CONFIG_PATH from core.db import get_all_users, upsert_user from core.mailer import send_brief_to -from core.ontrack import fetch_active_projects_direct, TokenExpiredError +from core.ontrack import TokenExpiredError, fetch_active_projects_direct logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger(__name__) @@ -75,11 +75,10 @@ def main() -> None: sys.exit(0) log.info("Found %d active project(s)", len(projects)) + # build_brief_direct doesn't surface a rotated token, so the freshest + # token we have is the one captured from the projects fetch above + # (already persisted on rotation). brief = build_brief_direct(base_url, fresh_token, username, projects) - final_token = get_last_seen_token() or fresh_token - if final_token != fresh_token: - log.info("Token rotated after build — saving ...%s", final_token[-6:]) - upsert_user(base_url, username, final_token, u["email"], u["brief_hour"]) today = date.today() pending_due = pending_due_entries(brief, today, 14) due_this_week = len(pending_due_entries(brief, today, 6)) diff --git a/scripts/test_token_lifetime.py b/scripts/test_token_lifetime.py index eddeb75..19855ed 100644 --- a/scripts/test_token_lifetime.py +++ b/scripts/test_token_lifetime.py @@ -8,7 +8,8 @@ Usage: python scripts/test_token_lifetime.py --token --username python scripts/test_token_lifetime.py --token --username --interval 300 - python scripts/test_token_lifetime.py --token --username --interval 300 --log token_lifetime.log + python scripts/test_token_lifetime.py --token --username \ + --interval 300 --log token_lifetime.log """ from __future__ import annotations diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_refresh_token_expiry.py b/tests/test_refresh_token_expiry.py new file mode 100644 index 0000000..dfada17 --- /dev/null +++ b/tests/test_refresh_token_expiry.py @@ -0,0 +1,361 @@ +""" +Tests: what happens when the refresh_token expires? + +Scenarios covered: + 1. mint_auth_token() raises RefreshTokenError on HTTP 401/403/419 + 2. mint_auth_token() raises RefreshTokenError on null (None) JSON body + 3. mint_auth_token() propagates requests.HTTPError on 5xx (transient) + 4. mint_auth_token() propagates RequestException on network failure + 5. /link-ontrack returns refresh_expired JSON when refresh_token is expired + 6. run_brief() still sends an email even when refresh_token is expired + (the morning brief is now purely DB-driven — no OnTrack call) + 7. run_brief() sends briefs-enabled confirmation on cold start (confirm_if_empty=True) + 8. run_brief() stays silent on cold start daily cron (confirm_if_empty=False) +""" + +import sys +import types +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Path setup +# --------------------------------------------------------------------------- +PROJECT_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +# Stub out heavyweight imports before any project modules are imported. +# Order matters: these must be in sys.modules before 'from core.xxx import ...' + +# --- Stub 'extensions' (APScheduler scheduler singleton) --- +ext_mod = types.ModuleType("extensions") +ext_mod.scheduler = MagicMock() +ext_mod.limiter = MagicMock() +# @limiter.limit(...) is used as a route decorator; make it a pass-through so the +# real view functions (and their __name__) survive blueprint registration. +ext_mod.limiter.limit.return_value = lambda f: f +sys.modules["extensions"] = ext_mod + +# --- Stub 'resend' (email SDK) --- +sys.modules.setdefault("resend", MagicMock()) + +# --- Stub jwt / PyJWKClient (Clerk) --- +jwt_mod = MagicMock() +sys.modules.setdefault("jwt", jwt_mod) + +# --- Stub 'core.crypto' so DB works without a real encryption key --- +crypto_mod = types.ModuleType("core.crypto") +crypto_mod.encrypt = lambda x: f"ENC:{x}" +crypto_mod.decrypt = lambda x: x[4:] if x and x.startswith("ENC:") else (x or "") +sys.modules["core.crypto"] = crypto_mod + +# --------------------------------------------------------------------------- +# Now import project modules. These intentionally follow the sys.modules stubs +# above, so the import-order lint rule (E402) is suppressed here on purpose. +# --------------------------------------------------------------------------- +import requests # noqa: E402 + +from core.ontrack.auth import RefreshTokenError, mint_auth_token # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helper: build a mock requests.Response +# --------------------------------------------------------------------------- +def _mock_response(status_code: int, json_data=None, content: bytes = b""): + r = MagicMock(spec=requests.Response) + r.status_code = status_code + r.ok = 200 <= status_code < 300 + if json_data is not None: + r.content = b"x" + r.json.return_value = json_data + else: + r.content = content + r.json.side_effect = ValueError("no content") + return r + + +# =========================================================================== +# 1. mint_auth_token — HTTP 401/403/419 → RefreshTokenError +# =========================================================================== + +@pytest.mark.parametrize("status_code", [401, 403, 419]) +def test_mint_auth_token_raises_on_auth_rejected(status_code): + """OnTrack returns an auth-rejection status -> RefreshTokenError (not transient).""" + session = MagicMock() + session.post.return_value = _mock_response(status_code, content=b"Unauthorized") + + with pytest.raises(RefreshTokenError, match="rejected"): + mint_auth_token( + "https://ontrack.deakin.edu.au", + "fake-refresh-token", + "s1234567", + session=session, + ) + + +# =========================================================================== +# 2. mint_auth_token — null (None) JSON body → RefreshTokenError +# =========================================================================== + +def test_mint_auth_token_raises_on_null_body(): + """OnTrack 201 + literal null body (no session resolved) -> RefreshTokenError.""" + session = MagicMock() + resp = MagicMock(spec=requests.Response) + resp.status_code = 201 + resp.ok = True + resp.content = b"null" + resp.json.return_value = None # null JSON body + session.post.return_value = resp + + with pytest.raises(RefreshTokenError, match="no session"): + mint_auth_token( + "https://ontrack.deakin.edu.au", + "expired-refresh-token", + "s1234567", + session=session, + ) + + +# =========================================================================== +# 3. mint_auth_token — 5xx → raises requests.HTTPError (transient, NOT expiry) +# =========================================================================== + +def test_mint_auth_token_5xx_is_transient_not_expiry(): + """A 5xx from OnTrack must NOT raise RefreshTokenError. + It should raise requests.HTTPError so callers can retry.""" + session = MagicMock() + resp = MagicMock(spec=requests.Response) + resp.status_code = 503 + resp.ok = False + resp.content = b"" + session.post.return_value = resp + + with pytest.raises(requests.HTTPError): + mint_auth_token( + "https://ontrack.deakin.edu.au", + "valid-refresh-token", + "s1234567", + session=session, + ) + + +# =========================================================================== +# 4. mint_auth_token — network timeout → propagates RequestException +# =========================================================================== + +def test_mint_auth_token_timeout_propagates_not_expiry(): + """A connection timeout must NOT be treated as token expiry.""" + session = MagicMock() + session.post.side_effect = requests.ConnectionError("timed out") + + with pytest.raises(requests.RequestException): + mint_auth_token( + "https://ontrack.deakin.edu.au", + "valid-refresh-token", + "s1234567", + session=session, + ) + + +# =========================================================================== +# 5. /link-ontrack endpoint → returns refresh_expired when token is dead +# =========================================================================== + +def test_link_ontrack_returns_refresh_expired_error(): + """/link-ontrack must return HTTP 401 + {error:'refresh_expired', hint:'open_ontrack'} + when mint_auth_token raises RefreshTokenError (the refresh_token has expired).""" + fake_user = { + "id": 1, + "base_url": "https://ontrack.deakin.edu.au", + "username": "s1234567", + "email": "student@test.com", + "auth_token": "old-token", + "refresh_token": "expired-token", + "token_valid": 1, + "brief_hour": 8, + "recently_completed_days": 7, + "max_todo_tasks": 10, + "subscribed": 1, + "token_fail_count": 0, + "brief_days": 14, + } + + with ( + patch("routes.main.get_user_by_clerk_id", return_value=None), + patch("routes.main.get_user_by_username", return_value=fake_user), + patch("routes.main.mint_auth_token", + side_effect=RefreshTokenError("OnTrack rejected the refresh_token — likely expired")), + patch("core.clerk_auth.verify_session_token", + return_value={"sub": "clerk_abc", "email": "student@test.com"}), + ): + from app import create_app + flask_app = create_app() + flask_app.config["TESTING"] = True + + with flask_app.test_client() as client: + resp = client.post( + "/link-ontrack", + json={ + "username": "s1234567", + "auth_token": "old-scraped-token", + "refresh_token": "expired-token", + }, + headers={"Authorization": "Bearer fake-clerk-jwt"}, + ) + data = resp.get_json() + assert resp.status_code == 401, \ + f"Expected 401 on expired refresh_token, got {resp.status_code}: {data}" + assert data.get("error") == "refresh_expired", \ + f"Expected error='refresh_expired', got: {data}" + assert data.get("hint") == "open_ontrack", \ + f"Expected hint='open_ontrack', got: {data}" + + +# =========================================================================== +# 6. run_brief() — still sends email even when refresh_token is expired +# (brief is purely DB-driven — no OnTrack / mint call happens) +# =========================================================================== + +def test_run_brief_sends_email_regardless_of_token_expiry(): + """The morning brief must go out from stored DB tasks regardless of token state. + An expired refresh_token has zero effect on the daily email.""" + from datetime import date + + from core import jobs as _jobs + + fake_user = { + "id": 42, + "email": "student@test.com", + "base_url": "https://ontrack.deakin.edu.au", + "username": "s1234567", + "subscribed": 1, + "brief_days": 14, + "brief_hour": 8, + "recently_completed_days": 7, + "max_todo_tasks": 10, + "refresh_token": "EXPIRED", # token is dead + "auth_token": "stale-token", + "token_valid": 0, # marked invalid + } + + fake_tasks = [ + { + "user_id": 42, + "project_id": 10, + "task_def_id": 1, + "abbreviation": "1.1P", + "name": "Setup task", + "unit_code": "SIT374", + "target_grade_label": "P (Pass)", + "deadline": "2099-12-31", + "status": "not_started", + "feedback_text": None, + "feedback_seen_at": None, + "last_seen": "2024-01-01T08:00:00", + } + ] + + fake_entries = [{ + "abbreviation": "1.1P", + "name": "Setup task", + "unit_code": "SIT374", + "status": "not_started", + "due": date(2099, 12, 31), + "deadline_iso": "2099-12-31", + "link": None, + "grade": None, + "target_grade_label": "P (Pass)", + "feedback_text": None, + }] + + with ( + patch("core.jobs.get_user_by_id", return_value=fake_user), + patch("core.jobs.get_capture_meta", return_value=(1, "2024-01-01T08:00:00")), + patch("core.jobs.get_pending_tasks", return_value=fake_tasks), + patch("core.jobs.send_brief_to", return_value=True) as mock_send, + patch("core.jobs.render_html", return_value="brief"), + patch("core.brief.pending_task_entries", return_value=fake_entries), + ): + _jobs.run_brief(42, confirm_if_empty=False) + + assert mock_send.called, \ + "BUG: send_brief_to was NOT called even though tasks exist and only token expired!" + assert mock_send.call_args[0][1] == "student@test.com", \ + "Email sent to wrong address" + + +# =========================================================================== +# 7. run_brief() — sends briefs-enabled confirmation on cold start +# =========================================================================== + +def test_run_brief_sends_confirmation_on_cold_start_with_explicit_enable(): + """When confirm_if_empty=True and no captured tasks (cold start), must send + the briefs-enabled confirmation — not silently skip.""" + from core import jobs as _jobs + + fake_user = { + "id": 1, + "email": "new@test.com", + "base_url": "https://ontrack.deakin.edu.au", + "username": "s0000001", + "subscribed": 1, + "brief_days": 14, + "brief_hour": 8, + "recently_completed_days": 7, + "max_todo_tasks": 10, + "refresh_token": None, + "auth_token": "token", + "token_valid": 1, + "token_fail_count": 0, + } + + with ( + patch("core.jobs.get_user_by_id", return_value=fake_user), + patch("core.jobs.get_capture_meta", return_value=(0, None)), + patch("core.jobs.send_briefs_enabled_email", return_value=True) as mock_confirm, + patch("core.jobs.send_brief_to") as mock_brief, + ): + _jobs.run_brief(1, confirm_if_empty=True) + + assert mock_confirm.called, \ + "BUG: briefs-enabled confirmation email was NOT sent on cold start!" + assert not mock_brief.called, \ + "send_brief_to should NOT be called when there are no tasks" + + +# =========================================================================== +# 8. run_brief() — daily cron stays silent on cold start +# =========================================================================== + +def test_run_brief_silent_on_cold_start_daily_cron(): + """Daily cron (confirm_if_empty=False) must stay silent when no tasks captured yet.""" + from core import jobs as _jobs + + fake_user = { + "id": 1, + "email": "new@test.com", + "base_url": "https://ontrack.deakin.edu.au", + "username": "s0000001", + "subscribed": 1, + "brief_days": 14, + "brief_hour": 8, + "recently_completed_days": 7, + "max_todo_tasks": 10, + "refresh_token": None, + "auth_token": "token", + "token_valid": 1, + "token_fail_count": 0, + } + + with ( + patch("core.jobs.get_user_by_id", return_value=fake_user), + patch("core.jobs.get_capture_meta", return_value=(0, None)), + patch("core.jobs.send_briefs_enabled_email") as mock_confirm, + patch("core.jobs.send_brief_to") as mock_brief, + ): + _jobs.run_brief(1, confirm_if_empty=False) + + assert not mock_confirm.called, "send_briefs_enabled_email should NOT fire on daily cron" + assert not mock_brief.called, "send_brief_to should NOT fire on cold start"
UnitTaskNameDueUnitTaskNameDue
+ @@ -83,10 +86,12 @@ def render_email( style="background:#ffffff;border:1px solid #e3e3e3;border-radius:14px">
-
+
{text}
-
+
{author}
@@ -97,7 +102,8 @@ def render_email(
-
+
{_escape(eyebrow)}
-
+
{heading}
-
+
OnTrack Brief