From 71d2b76df34f78be8885fbfc2bf02ddbba9684b6 Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Fri, 26 Jun 2026 06:04:28 -0700 Subject: [PATCH 01/20] Cleaned up files and Added Files for OSS release --- .github/ISSUE_TEMPLATE/bug_report.md | 42 +++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.md | 29 ++ .github/PULL_REQUEST_TEMPLATE.md | 28 ++ CODE_OF_CONDUCT.md | 139 ++++++++ CONTRIBUTING.md | 102 ++++++ DEPENDENCIES.md | 6 +- README.md | 15 +- SECURITY.md | 58 ++++ docs/privacy.md | 2 +- docs/security-gap-report.md | 365 ---------------------- pyproject.toml | 18 +- 12 files changed, 441 insertions(+), 371 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 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md delete mode 100644 docs/security-gap-report.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..18c0a07 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,42 @@ +--- +name: Bug report +about: Report a problem to help us improve Node Wire +title: "[Bug] " +labels: bug +assignees: '' +--- + +**Do not report security vulnerabilities here.** Follow the +[Security Policy](../../SECURITY.md) instead. + +## Describe the bug + +A clear and concise description of what the bug is. + +## To Reproduce + +Steps to reproduce the behavior: + +1. ... +2. ... + +## Expected behavior + +What you expected to happen. + +## Environment + +- Node Wire version / commit: +- Binding mode (REST / gRPC / MCP): +- Connector(s) involved: +- Python version: +- OS: + +## Logs / context + +Add any relevant logs (with secrets and PHI/PII redacted) or additional context. + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..fe19ebe --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/AOT-Technologies/node-wire/security/advisories/new + about: Please report security vulnerabilities privately, not as public issues. See SECURITY.md. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..222fdd2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,29 @@ +--- +name: Feature request +about: Suggest an idea or enhancement for Node Wire +title: "[Feature] " +labels: enhancement +assignees: '' +--- + +## Problem + +What problem are you trying to solve? Is your request related to a limitation +you've hit? + +## Proposed solution + +A clear and concise description of what you want to happen. + +## Alternatives considered + +Any alternative solutions or features you've considered. + +## Additional context + +Add any other context, references, or screenshots about the request here. + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..84164ee --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ + + +## Description + + + +## Related Issue + + + +## Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation +- [ ] Refactor / chore + +## Checklist + +- [ ] I ran the quality checks locally (`ruff`, `mypy`, `bandit`, `pytest`). +- [ ] New files include the required SPDX/REUSE license header. +- [ ] I added or updated tests where appropriate. +- [ ] I updated documentation where appropriate. +- [ ] My commits use a correctly configured git identity (real name and email). diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..4aa2600 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,139 @@ + + +# 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, caste, color, 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 for our +community include: + +* 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, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their 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 of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +**opensource@aot-technologies.com**. + +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. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## 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][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..40778a3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,102 @@ + + +# Contributing to Node Wire + +Thanks for your interest in contributing! This guide covers how to set up a +development environment, the quality checks we enforce, and the conventions for +submitting changes. By contributing you agree that your contributions are +licensed under the project's [Apache License 2.0](LICENSE). + +Please also read our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Prerequisites + +| Requirement | Version | Notes | +|---|---|---| +| Python | 3.11+ | Required to run the platform | +| `uv` | Latest | Recommended for dependency management and reproducible installs | +| Git | Any recent version | | +| Docker | Latest | Only needed for MCP server image builds | + +## Development Setup + +Install the project and all development dependencies from the committed +lockfile: + +```bash +git clone https://github.com/AOT-Technologies/node-wire.git +cd node-wire +uv sync --frozen --all-extras --dev +``` + +Install the pre-commit hooks so checks run automatically before each commit: + +```bash +pre-commit install +``` + +## Quality Checks + +All of the following run in CI on pull requests against `main`. Run them +locally before opening a PR: + +- **Lint:** `uv run ruff check .` +- **Auto-fix & format:** `uv run ruff check --fix . && uv run ruff format .` +- **Type-check:** `uv run mypy` (uses the `src` target from `pyproject.toml`; + avoid `mypy .`, which pulls in packaging `setup.py` files and produces + duplicate-module noise) +- **Security (SAST):** `uv run bandit -c pyproject.toml -r src` +- **Tests:** `uv run pytest` + +See [docs/code-quality-compliance.md](docs/code-quality-compliance.md) and +[docs/quality-security-gates.md](docs/quality-security-gates.md) for the full +tooling reference. + +## Licensing & REUSE Compliance + +This project is [REUSE](https://reuse.software/) compliant. **Every new file +must carry an SPDX header.** For source and Markdown files use the project's +standard header, e.g. for Python: + +```python +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 +``` + +and for Markdown/HTML, an HTML comment with the same two SPDX tags. The +pre-commit and CI checks will fail if a file is missing its header. + +## Git Identity + +> **Configure your git identity before committing.** Commits must use your real +> name and a valid email so attribution is accurate: +> +> ```bash +> git config user.name "Your Name" +> git config user.email "you@example.com" +> ``` +> +> Do not commit with placeholder identities (e.g. an unconfigured `My Name` +> default). Misconfigured identities get carried into the project history via +> squash-merge co-author trailers and are difficult to remove later. + +## Submitting Changes + +1. Fork the repository and create a feature branch from `main` + (e.g. `feature/short-description` or `fix/short-description`). +2. Make your change, including tests and documentation updates where relevant. +3. Ensure all quality checks above pass locally. +4. Open a pull request against `main` with a clear description of the change and + the motivation behind it. Link any related issue. +5. A maintainer will review your PR. Address review feedback by pushing + additional commits to your branch. + +## Reporting Bugs & Requesting Features + +Use the GitHub issue templates. For **security vulnerabilities**, do not open a +public issue — follow the [Security Policy](SECURITY.md) instead. diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index dd83324..5534e83 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -4,9 +4,9 @@ This file is automatically generated and contains an inventory of all third-part ## License Classification Criteria To maintain open-source compliance, dependencies are evaluated against the following criteria: -* **? Safe (Permissive):** MIT, Apache-2.0, BSD, PSF. These licenses are universally safe for our Apache 2.0 open-source release and can be freely used, modified, and distributed. -* **?? Needs Review:** Custom or obscure licenses. These require manual review by the engineering team to ensure they don't impose conflicting obligations. -* **? Risky (Copyleft):** GPLv2, GPLv3, AGPL. These licenses are strictly prohibited in the runtime application as they force derivative works to adopt the same open-source license. They may only be used as isolated, non-distributed Development/Linting tools. +* **✅ Safe (Permissive):** MIT, Apache-2.0, BSD, PSF. These licenses are universally safe for our Apache 2.0 open-source release and can be freely used, modified, and distributed. +* **⚠️ Needs Review:** Custom or obscure licenses. These require manual review by the engineering team to ensure they don't impose conflicting obligations. +* **⛔ Risky (Copyleft):** GPLv2, GPLv3, AGPL. These licenses are strictly prohibited in the runtime application as they force derivative works to adopt the same open-source license. They may only be used as isolated, non-distributed Development/Linting tools. --- diff --git a/README.md b/README.md index facd897..285448a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Before getting started, make sure you have: ### 1. Install ```bash -git clone +git clone https://github.com/AOT-Technologies/node-wire.git cd node-wire uv sync --frozen --all-extras --dev ``` @@ -208,6 +208,19 @@ For more detailed information, please refer to the following guides: --- +## Contributing + +Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for +the development setup, quality checks, and PR conventions, and our +[Code of Conduct](CODE_OF_CONDUCT.md). + +## Security + +To report a vulnerability, please follow our [Security Policy](SECURITY.md). Do +not open a public issue for security reports. + +--- + ## License This project is licensed under the Apache License 2.0. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9086ebf --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,58 @@ + + +# Security Policy + +The Node Wire maintainers take the security of the project seriously. This +document explains which versions receive security fixes and how to report a +vulnerability responsibly. + +## Supported Versions + +Node Wire is pre-1.0 and under active development. Security fixes are applied to +the latest released minor version and the `main` branch. + +| Version | Supported | +| ------- | ------------------ | +| 0.1.x | :white_check_mark: | +| < 0.1 | :x: | + +## Reporting a Vulnerability + +**Please do not open a public GitHub issue for security vulnerabilities.** + +Report suspected vulnerabilities privately through either of the following: + +- **GitHub Security Advisories** — use the + [private vulnerability reporting](https://github.com/AOT-Technologies/node-wire/security/advisories/new) + form for this repository (preferred). +- **Email** — send details to **security@aot-technologies.com**. + + +When reporting, please include as much of the following as you can: + +- A description of the vulnerability and its potential impact. +- The component or file involved (e.g. a connector, binding, or runtime module). +- Steps to reproduce, including a minimal proof of concept if available. +- The version, commit, or deployment configuration affected. + +## What to Expect + +- **Acknowledgement** within 3 business days of your report. +- An initial assessment and severity triage within 10 business days. +- Coordinated disclosure: we will work with you on a fix and a public + disclosure timeline, and credit you in the advisory unless you prefer to + remain anonymous. + +Please give us a reasonable opportunity to remediate the issue before any +public disclosure. + +## Scope + +This policy covers the code in this repository: the runtime, connectors, and +bindings. Vulnerabilities in third-party dependencies should be reported to the +relevant upstream project; if a dependency issue affects Node Wire users, we +still welcome a heads-up so we can pin or patch accordingly. diff --git a/docs/privacy.md b/docs/privacy.md index bfe883e..8395039 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -31,4 +31,4 @@ By default, Node-Wire logging is configured to provide operational visibility wi ## Security Disclosures -If you discover a potential privacy or security vulnerability within Node-Wire, please do not disclose it publicly. Refer to our [Security Policy](security-gap-report.md) for instructions on how to securely report issues to the maintainers. +If you discover a potential privacy or security vulnerability within Node-Wire, please do not disclose it publicly. Refer to our [Security Policy](../SECURITY.md) for instructions on how to securely report issues to the maintainers. diff --git a/docs/security-gap-report.md b/docs/security-gap-report.md deleted file mode 100644 index e9ee129..0000000 --- a/docs/security-gap-report.md +++ /dev/null @@ -1,365 +0,0 @@ - - -# Security & Architecture Gap Report — Node Wire MCP Platform - -> **Perspective:** Secure MCP server platform integrator reviewing the runtime and connectors for production readiness. -> **Date:** 2026-04-01 -> **Branch:** `feature/python-packages` - ---- - -## Executive Summary - -The platform has a solid foundation: clean layered architecture (runtime → connectors → bindings), Pydantic-enforced input validation, OpenTelemetry observability, and resilience patterns (circuit breaker, retry). However, **critical security gaps must be addressed before production use**, particularly around authentication, credential management, PHI/PII logging, and network security. - -**Finding counts:** 5 Critical · 10 High · 7 Medium · 5 Low - ---- - -## Severity Definitions - -| Severity | Meaning | -|----------|---------| -| **CRITICAL** | Exploit path exists now; immediate remediation required | -| **HIGH** | Significant attack surface; address before production | -| **MEDIUM** | Increases risk; address in next sprint | -| **LOW** | Best-practice gap; backlog item | - ---- - -## CRITICAL Findings - -### C1 — Credentials in `.env` Committed to Repository - -- **Location:** `.env` (repository root) -- **What's exposed:** `EPIC_PRIVATE_KEY` (RSA private key), `CERNER_PRIVATE_KEY`, `EPIC_CLIENT_ID`, `GROQ_API_KEY`, `SMTP_PASSWORD`, path to `connectorplatform-*.json` service account file -- **Impact:** Anyone with read access to the repo can impersonate the Epic/Cerner OAuth client, read Google Drive, send email as the platform, and call Groq -- **Fix:** - 1. Revoke all exposed credentials immediately and rotate - 2. Add `.env` and `connectorplatform-*.json` to `.gitignore` - 3. Move secrets to a secrets manager (HashiCorp Vault, AWS Secrets Manager, K8s Secrets) - ---- - -### C2 — PHI Logged in Error Paths (HIPAA Violation) - -- **Location:** `src/connectors/fhir_epic/logic.py` (~line 485), `src/connectors/fhir_cerner/logic.py` (~line 592) -- **What's logged:** Full FHIR `DocumentReference` payload on failure — contains patient names, birthdates, MRNs, diagnoses -- **Code pattern:** - ```python - logger.error("... sent_payload=%s", json.dumps(doc_ref)) - ``` -- **Impact:** Violates HIPAA § 164.312(b) audit controls; PHI written to log aggregation systems in plaintext -- **Fix:** Log only resource type, resource ID, and HTTP status code. Implement a `PHIScrubber` log filter for all healthcare connectors - ---- - -### C3 — No Authentication on REST API or gRPC Binding - -- **Location:** `src/bindings/rest_api/app.py`, `src/bindings/grpc_server/server.py` -- **What's missing:** Zero authentication or authorization on any endpoint -- **gRPC uses an insecure port:** - ```python - server.add_insecure_port(f"[::]:{port}") # no TLS, no mTLS - ``` -- **Impact:** Any network-adjacent caller can invoke any connector action with no audit trail -- **Fix:** - - REST: Add API key or OAuth2 bearer token middleware to FastAPI - - gRPC: Switch to `add_secure_port` with TLS credentials; enforce mTLS for service-to-service - ---- - -### C4 — SSRF via HTTP Generic Connector - -- **Location:** `src/connectors/http_generic/schema.py`, `src/connectors/http_generic/logic.py` -- **What's missing:** `HttpUrl` validates URL format but not destination host -- **Attack path:** - ```json - { "url": "http://169.254.169.254/latest/meta-data", "method": "GET" } - ``` - → Returns AWS instance metadata including IAM credentials -- **Fix:** Block RFC-1918, loopback (`127.0.0.0/8`), and link-local (`169.254.0.0/16`) address ranges at the schema validator level; optionally implement an egress allowlist - ---- - -### C5 — Configurable Secret Key Names in SMTP Connector - -- **Location:** `src/connectors/smtp/schema.py` (fields `username_secret_key`, `password_secret_key`) -- **Attack path:** Caller provides `"username_secret_key": "STRIPE_API_KEY"` → connector fetches the Stripe key and uses it as an SMTP credential; SMTP auth error may reveal whether the key value exists or its format -- **Impact:** Secret enumeration and partial exfiltration via error side-channel -- **Fix:** Hardcode secret key names inside the connector; remove `username_secret_key` and `password_secret_key` from the public input schema entirely - ---- - -## HIGH Findings - -### H1 — OAuth Error Response Body Logged - -- **Location:** `src/connectors/fhir_epic/logic.py` (~line 130), `src/connectors/fhir_cerner/logic.py` -- **What's logged:** Full `token_response.text` on OAuth failure — may include client credential reflections, token hints, or infrastructure error details -- **Fix:** Log only the `error` and `error_description` fields from the JSON response - ---- - -### H2 — Unvalidated HTTP Method in Generic Connector - -- **Location:** `src/connectors/http_generic/schema.py` -- **Current:** `method: str` — accepts any string value -- **Risk:** Arbitrary or non-standard HTTP methods forwarded to target servers; undefined server behavior -- **Fix:** - ```python - method: Literal["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] - ``` - ---- - -### H3 — Stripe Global API Key (Race Condition) - -- **Location:** `src/connectors/stripe/logic.py` (~line 48) -- **Pattern:** `stripe.api_key = api_key` mutates global module state -- **Risk:** Concurrent requests clobber each other's API key; Stripe exceptions may include the key value in tracebacks -- **Fix:** Pass `api_key=` explicitly to each Stripe API call rather than setting global state - ---- - -### H4 — Auto-Discovery Loads All Connector Modules Without Allowlist - -- **Location:** `src/connectors/__init__.py` (`auto_register()`) -- **Risk:** Any file placed at `src/connectors/*/logic.py` is imported and executed automatically on startup — no explicit allowlist -- **Fix:** Validate each discovered connector ID against an explicit allowlist from `connectors.yaml`; skip unknown modules with a logged warning - ---- - -### H5 — No Rate Limiting on Any Binding - -- **Location:** All binding layers (REST, gRPC, MCP) -- **Risk:** Unlimited invocation rate; no protection against DoS, credential-stuffing, or API quota exhaustion at upstream services -- **Fix:** Add `slowapi` (or equivalent) rate-limiting middleware; define per-tenant quotas in connector configuration - ---- - -### H6 — Circuit Breaker Shared Across Tenants - -- **Location:** `src/runtime/base_connector.py` (constructor — `self._breaker = CircuitBreaker(...)`) -- **Risk:** One tenant triggering repeated failures opens the circuit breaker for all tenants — classic noisy-neighbor DoS -- **Fix:** Key the circuit breaker on `(connector_id, tenant_id)` rather than connector class alone - ---- - -### H7 — Unvalidated Base64 Content in FHIR Connectors - -- **Location:** `src/connectors/fhir_cerner/logic.py`, `src/connectors/fhir_epic/logic.py` -- **Pattern:** - ```python - attachment["data"] = params.data # no format check, no size limit - ``` -- **Risk:** Malformed base64 forwarded to EHR; unbounded payload size enables memory exhaustion -- **Fix:** Add a Pydantic `field_validator` that calls `base64.b64decode(v, validate=True)` and enforces a max size (e.g., 10 MB) - ---- - -### H8 — No Dependency Vulnerability Scanning - -- **Location:** `pyproject.toml`, `uv.lock` -- **Gap:** No `pip-audit`, `safety`, or Dependabot configured; several deps use unbounded `>=VERSION` (e.g., `tenacity>=8.2.0`, `fastapi>=0.111.0`, `uvicorn>=0.30.0`) -- **Fix:** Add `pip-audit` to CI pipeline; add upper bounds to all runtime dependencies; add `detect-secrets` as a pre-commit hook - ---- - -### H9 — No Structured Audit Trail for Policy Hook Decisions - -- **Location:** `src/runtime/base_connector.py` (policy hook execution block) -- **Gap:** Policy denials are logged at `WARNING` level but not emitted as structured security events queryable by a SIEM -- **Fix:** Emit a structured `POLICY_DENIED` event containing `principal`, `tenant_id`, `connector_id`, `action`, and `reason` to a dedicated audit log sink - ---- - -### H10 — Zero Security Test Coverage - -- **Location:** `tests/` (all files) -- **Gaps:** No tests exist for authentication failures, SSRF attempts, malformed payloads, credential leakage in error messages, multi-tenant isolation, rate limit enforcement, or concurrent state safety -- **Fix:** Add a `tests/security/` suite covering at minimum: - - SSRF via HTTP Generic (`127.0.0.1`, `169.254.169.254`) - - Secret enumeration via SMTP `username_secret_key` - - PHI absence in FHIR error log output - - Circuit breaker isolation across tenants - ---- - -## MEDIUM Findings - -### M1 — Stripe Input Has No Validation Bounds - -- **Location:** `src/connectors/stripe/schema.py` -- `amount: int` — no minimum (1 cent) or maximum cap -- `currency: str` — no ISO 4217 pattern check -- **Fix:** - ```python - amount: int = Field(..., ge=1, le=999_999_999) - currency: str = Field(..., pattern=r'^[A-Z]{3}$') - ``` - ---- - -### M2 — Config Variable Substitution (`${VAR:default}`) Not Implemented - -- **Location:** `src/bindings/factory.py`; `config/connectors.yaml` uses `${VAR:default}` syntax in comments and values -- **Current behavior:** Variables are loaded as literal strings — the `${...}` is never expanded -- **Fix:** Implement regex-based substitution in the YAML loader; raise at startup if a required variable is unset - ---- - -### M3 — Factory Returns `None` on Missing Connector (Silent Failure) - -- **Location:** `src/bindings/factory.py` (`get_for_protocol()`) -- **Risk:** A misconfigured connector silently returns `None`; failures surface at request time rather than startup -- **Fix:** Validate all enabled connectors during factory initialization and raise immediately on any misconfiguration - ---- - -### M4 — Hardcoded Timeouts and Circuit Breaker Parameters - -- **Location:** `src/connectors/http_generic/logic.py` (`timeout=30.0`), `src/runtime/base_connector.py` (`fail_max=5, reset_timeout=30`) -- **Risk:** A 30-second timeout is inappropriate for large FHIR document uploads; a 5-failure threshold may be too sensitive for high-traffic deployments -- **Fix:** Expose these as per-connector configuration keys in `connectors.yaml` - ---- - -### M5 — OpenTelemetry Trace Data May Export PHI - -- **Location:** `src/runtime/observability.py` -- **Risk:** Span attributes populated by FHIR connectors may include patient identifiers that flow unfiltered to the OTLP collector -- **Fix:** Add a `SpanSanitizer` processor that removes or hashes known PHI field names before export - ---- - -### M6 — Google Drive `query` Parameter Accepts Arbitrary String - -- **Location:** `src/connectors/google_drive/schema.py` (`query: Optional[str]`) -- **Risk:** No client-side validation; the platform relies entirely on Google's server-side handling of malformed or adversarial query strings -- **Fix:** Document and enforce the allowed query syntax subset; reject queries that don't match a safe pattern - ---- - -### M7 — Service Account File Path Not Sandboxed - -- **Location:** `src/connectors/google_drive/logic.py` -- **Pattern:** `Credentials.from_service_account_file(raw_sa.strip())` — path is fully controlled by the env var -- **Risk:** Path traversal if the environment variable is tampered with -- **Fix:** Resolve the path with `Path.resolve()` and assert it falls within the application directory before opening - ---- - -## LOW Findings - -### L1 — SMTP Connector Logs Recipient Email Addresses - -- **Location:** `src/connectors/smtp/logic.py` -- `"from_email": str(params.from_email)` written to structured log output -- **Risk:** Email addresses are PII; log aggregators retain them indefinitely, creating a compliance liability -- **Fix:** Log only recipient count and domain (e.g., `example.com`), never full addresses - ---- - -### L2 — MCP Manifest Lacks Security Metadata - -- **Location:** `src/connectors/manifest.py` -- **Missing:** Required OAuth scopes, auth requirements, per-action rate limits, deprecation status -- **Impact:** LLM clients have no way to determine required permissions before invoking a tool -- **Fix:** Add an optional `security` block to each manifest entry describing required scopes and auth type - ---- - -### L3 — No MCP Prompt Templates Defined - -- **Location:** `src/bindings/mcp_server/server.py` -- **Gap:** The MCP spec supports pre-built prompt templates to guide safe, correct tool use -- **Risk:** Without templates, LLM clients must independently discover correct multi-step usage patterns (e.g., FHIR patient lookup → document create) -- **Fix:** Define prompt templates for common connector flows - ---- - -### L4 — No Sampling or Pagination Limits in MCP Binding - -- **Location:** `src/bindings/mcp_server/server.py` -- **Gap:** A single `files.list` or FHIR search with a large page size could return megabytes of data in one tool response -- **Fix:** Enforce maximum page sizes at the MCP binding layer; add streaming for large result sets - ---- - -### L5 — PEM Key Reconstruction Is Brittle - -- **Location:** `src/connectors/fhir_cerner/logic.py`, `src/connectors/fhir_epic/logic.py` -- **Pattern:** `private_key_str.replace("\\n", "\n")` to reconstruct a PEM key from env var -- **Risk:** Silently produces an invalid key if the env var format is wrong; error only surfaces at JWT signing time -- **Fix:** Parse and validate the key with the `cryptography` library at connector startup; reject the connector if the key is unparseable - ---- - -## Summary Table - -| ID | Category | Issue | Severity | -|----|----------|-------|----------| -| C1 | Credentials | `.env` with real secrets committed to repo | CRITICAL | -| C2 | Privacy | PHI logged in FHIR error paths | CRITICAL | -| C3 | AuthN/AuthZ | No authentication on REST or gRPC bindings | CRITICAL | -| C4 | Network | SSRF via HTTP Generic connector | CRITICAL | -| C5 | AuthN | Configurable secret key names in SMTP | CRITICAL | -| H1 | Privacy | OAuth error response body logged | HIGH | -| H2 | Validation | Unvalidated HTTP method in generic connector | HIGH | -| H3 | Concurrency | Stripe global API key mutation (race condition) | HIGH | -| H4 | Supply Chain | All connector modules auto-loaded without allowlist | HIGH | -| H5 | DoS | No rate limiting on any binding | HIGH | -| H6 | Isolation | Circuit breaker shared across all tenants | HIGH | -| H7 | Validation | Unvalidated base64 content in FHIR connectors | HIGH | -| H8 | Dependencies | No CVE scanning; unbounded version ranges | HIGH | -| H9 | Audit | Policy hook denials not structured/auditable | HIGH | -| H10 | Testing | Zero security test coverage | HIGH | -| M1 | Validation | Stripe amount/currency unbounded | MEDIUM | -| M2 | Config | `${VAR}` substitution not implemented | MEDIUM | -| M3 | Reliability | Silent `None` on missing connector config | MEDIUM | -| M4 | Config | Hardcoded timeouts and circuit breaker params | MEDIUM | -| M5 | Privacy | OTel traces may export PHI to collector | MEDIUM | -| M6 | Validation | Drive query accepts arbitrary string | MEDIUM | -| M7 | Path Safety | Service account file path not sandboxed | MEDIUM | -| L1 | Privacy | SMTP logs full recipient email addresses | LOW | -| L2 | MCP | Manifest lacks security metadata (scopes, auth) | LOW | -| L3 | MCP | No MCP prompt templates defined | LOW | -| L4 | MCP | No sampling/pagination limits in MCP binding | LOW | -| L5 | Reliability | Brittle PEM key reconstruction from env var | LOW | - ---- - -## Recommended Remediation Order - -### Immediate — before any external network access - -1. Revoke all credentials in `.env`; rotate FHIR private keys, Groq key, SMTP password (C1) -2. Remove PHI from FHIR error log lines (C2) -3. Add API key middleware to REST binding (C3) -4. Block RFC-1918 / loopback hosts in HTTP Generic URL validator (C4) -5. Hardcode SMTP secret key names; remove from input schema (C5) - -### Before production - -6. Add TLS + mTLS to gRPC server (C3 continuation) -7. Add connector allowlist validation in `auto_register()` (H4) -8. Add rate limiting middleware to all bindings (H5) -9. Add `pip-audit` to CI and pin dependency upper bounds (H8) -10. Write `tests/security/` suite (H10) - -### Next sprint - -11. Implement per-tenant circuit breakers (H6) -12. Add OTLP `SpanSanitizer` for PHI fields (M5) -13. Implement `${VAR:default}` config substitution (M2) -14. Add Stripe `amount`/`currency` field validators (M1) -15. Add manifest `security` metadata block (L2) - ---- - -*Generated: 2026-04-01 | Branch: feature/python-packages* diff --git a/pyproject.toml b/pyproject.toml index c88ea8c..1e19110 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,9 +8,19 @@ version = "0.1.0" description = "Node Wire — runtime, connectors, and bindings" requires-python = ">=3.11" authors = [ - { name = "AOT", email = "dev@aot.local" }, + { name = "AOT Technologies", email = "opensource@aot-technologies.com" }, ] readme = "README.md" +keywords = ["connectors", "mcp", "rest", "grpc", "integration", "runtime"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Application Frameworks", +] dependencies = [ "pydantic>=2.6.0,<3.0.0", @@ -36,6 +46,12 @@ dependencies = [ "traceloop-sdk>=0.53.0", ] +[project.urls] +Homepage = "https://github.com/AOT-Technologies/node-wire" +Repository = "https://github.com/AOT-Technologies/node-wire" +Issues = "https://github.com/AOT-Technologies/node-wire/issues" +Documentation = "https://github.com/AOT-Technologies/node-wire/tree/main/docs" + [project.scripts] node-wire = "bindings_entrypoint:main" nw-google-drive = "agents.google_drive_mcp:main" From 0c5a3648559d0d3c4da7705d8f747574668445e6 Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Fri, 26 Jun 2026 07:19:47 -0700 Subject: [PATCH 02/20] fix: correct MCP auth flag, close HTTP SSRF gap, and resolve quality follow-ups --- docs/mcp-servers.md | 8 ++- pyproject.toml | 2 +- sample.env | 12 +++- src/bindings/grpc_server/server.py | 5 +- src/bindings/grpc_server/tls_config.py | 46 ++++++++++++-- src/bindings/mcp_server/auth.py | 45 +++++++++++++- src/bindings/mcp_server/server.py | 16 ++++- src/node_wire_http_generic/logic.py | 65 +++++++++++++++++++- src/node_wire_http_generic/schema.py | 48 ++++++++++++--- src/node_wire_runtime/resilience.py | 80 +++++++++++++++--------- src/node_wire_salesforce/logic.py | 20 +++++- src/node_wire_slack/logic.py | 16 +++-- tests/conftest.py | 4 +- tests/test_connectors_io.py | 83 ++++++++++++++++++++++++- tests/test_mcp_auth.py | 84 +++++++++++++++++++++----- tests/test_network_bindings.py | 54 ++++++++++++++++- tests/test_runtime_resilience.py | 35 +++++++++++ tests/test_slack_connector.py | 67 ++++++++++++++++++++ uv.lock | 4 +- 19 files changed, 611 insertions(+), 83 deletions(-) diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index 7a13820..2b8ed24 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -163,13 +163,19 @@ When running in `streamable-http` mode, clients must comply with the strict MCP Use these settings for production-style posture: ```env -NW_MCP_AUTH_ENABLED=false +# MCP auth is ENABLED by default — do NOT set NW_MCP_AUTH_DISABLED in production. +# (Set NW_MCP_AUTH_DISABLED=true only for local development.) +NW_MCP_API_KEY=replace-with-strong-random-value NW_MCP_SCOPE_POLICY_DEFAULT=deny # Optional guardrail: fail startup if scope policy would be disabled NW_MCP_SCOPE_POLICY_STRICT=true ``` Notes: +- MCP authentication is enforced unless `NW_MCP_AUTH_DISABLED=true` (the flag mirrors + `NW_REST_AUTH_DISABLED` / `NW_GRPC_AUTH_DISABLED`). The legacy `NW_MCP_AUTH_ENABLED` + flag is deprecated; it now honours its literal meaning (`=false` disables auth) and + logs a deprecation warning. Prefer `NW_MCP_AUTH_DISABLED`. - Code default is `deny` when `NW_MCP_SCOPE_POLICY_DEFAULT` is unset (fail-closed). - `NW_MCP_SCOPE_POLICY_DEFAULT=deny` enforces fallback scope `mcp:.` even when no explicit action map is present. - Keep `NW_MCP_ACTION_SCOPE_MAP_JSON` for custom scope names across tools. diff --git a/pyproject.toml b/pyproject.toml index 1e19110..db80cb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ dependencies = [ "pydantic>=2.6.0,<3.0.0", "tenacity>=8.2.0", - "pybreaker>=1.0.0", + "pybreaker>=1.0.0,<2.0.0", "opentelemetry-api>=1.24.0", "opentelemetry-sdk>=1.24.0", "fastapi>=0.111.0", diff --git a/sample.env b/sample.env index d0f6ba1..6ae73af 100644 --- a/sample.env +++ b/sample.env @@ -110,9 +110,12 @@ GEMINI_MODEL=gemini-2.5-flash ANTHROPIC_API_KEY=your-anthropic-api-key ANTHROPIC_MODEL=claude-3-5-haiku-20241022 -# MCP auth — NW_MCP_AUTH_ENABLED=true means auth is **disabled** (legacy naming; local dev). -# For production, omit it or set false so MCP auth is enforced when NW_MCP_API_KEY / JWT is set. -NW_MCP_AUTH_ENABLED=true +# MCP auth — NW_MCP_AUTH_DISABLED=true disables auth (local dev only), matching +# NW_REST_AUTH_DISABLED / NW_GRPC_AUTH_DISABLED. The default (unset) ENFORCES auth. +# For production, omit this and set NW_MCP_API_KEY (and/or NW_MCP_JWT_SECRET). +# (The legacy NW_MCP_AUTH_ENABLED flag is deprecated; it now honours its literal +# meaning — NW_MCP_AUTH_ENABLED=false disables auth — and logs a warning.) +NW_MCP_AUTH_DISABLED=true NW_MCP_API_KEY=replace-with-strong-random-value # API key scopes (JSON array or space/comma-separated). Empty = no scopes; use "*" only for explicit full access. # Wildcard API keys intentionally bypass per-action scope checks. @@ -154,6 +157,9 @@ NW_REST_LOAD_DOTENV=true # NW_GRPC_TLS_CERT_PATH=/path/to/cert.pem # NW_GRPC_TLS_KEY_PATH=/path/to/key.pem # NW_GRPC_REQUIRE_TLS=true +# Bind host: defaults to 127.0.0.1 (local only), matching NW_MCP_HOST. Set to +# :: (or 0.0.0.0) to expose gRPC on all interfaces for remote access. +# NW_GRPC_HOST=:: # MCP contract (optional; Google Drive legacy payload `action: "upload"`) # NODE_WIRE_LEGACY_GDRIVE_ACTION_UPLOAD=warn diff --git a/src/bindings/grpc_server/server.py b/src/bindings/grpc_server/server.py index e433786..af666ab 100644 --- a/src/bindings/grpc_server/server.py +++ b/src/bindings/grpc_server/server.py @@ -21,7 +21,7 @@ from .async_runner import BackgroundAsyncRunner from . import connector_pb2, connector_pb2_grpc # type: ignore[attr-defined] from .auth import GrpcAuthInterceptor, get_grpc_caller_identity -from .tls_config import configure_grpc_server_port +from .tls_config import configure_grpc_server_port, resolve_grpc_host logger = logging.getLogger("bindings.grpc_server") @@ -113,7 +113,8 @@ def serve(port: int = 50051) -> None: cert_path = os.environ.get("NW_GRPC_TLS_CERT_PATH") key_path = os.environ.get("NW_GRPC_TLS_KEY_PATH") - configure_grpc_server_port(server, port=port, cert_path=cert_path, key_path=key_path) + host = resolve_grpc_host() + configure_grpc_server_port(server, port=port, host=host, cert_path=cert_path, key_path=key_path) server.start() server.wait_for_termination() diff --git a/src/bindings/grpc_server/tls_config.py b/src/bindings/grpc_server/tls_config.py index f9229f2..4dd75fb 100644 --- a/src/bindings/grpc_server/tls_config.py +++ b/src/bindings/grpc_server/tls_config.py @@ -13,6 +13,29 @@ logger = logging.getLogger("bindings.grpc_server") +# Mirror the MCP binding's host model (bindings/mcp_server/server.py): default to +# loopback and require an explicit opt-in to expose the server on all interfaces. +_DEFAULT_GRPC_HOST = "127.0.0.1" +_PUBLIC_BIND_HOSTS = frozenset({"0.0.0.0", "::"}) + + +def resolve_grpc_host(env_value: str | None = None) -> str: + """Resolve the gRPC bind host from ``NW_GRPC_HOST`` (default ``127.0.0.1``).""" + if env_value is not None: + return env_value.strip() + return os.getenv("NW_GRPC_HOST", _DEFAULT_GRPC_HOST).strip() + + +def is_public_bind_host(host: str) -> bool: + return host in _PUBLIC_BIND_HOSTS + + +def _format_bind_target(host: str, port: int) -> str: + """Build a gRPC bind string, bracketing IPv6 literals (``[::1]:50051``).""" + if ":" in host and not host.startswith("["): + return f"[{host}]:{port}" + return f"{host}:{port}" + def _tls_configured(cert_path: str | None, key_path: str | None) -> bool: return bool(cert_path and key_path) @@ -22,6 +45,7 @@ def configure_grpc_server_port( server: grpc.Server, *, port: int, + host: str | None = None, cert_path: str | None = None, key_path: str | None = None, require_tls: bool | None = None, @@ -30,6 +54,17 @@ def configure_grpc_server_port( if require_tls is None: require_tls = _truthy(os.environ.get("NW_GRPC_REQUIRE_TLS")) + if host is None: + host = resolve_grpc_host() + bind_target = _format_bind_target(host, port) + + if is_public_bind_host(host): + logger.warning( + "gRPC server binding to all interfaces; set NW_GRPC_HOST=127.0.0.1 " + "for local-only access", + extra={"host": host, "port": port}, + ) + if _tls_configured(cert_path, key_path): assert key_path is not None assert cert_path is not None @@ -39,8 +74,11 @@ def configure_grpc_server_port( certificate_chain = f.read() server_credentials = grpc.ssl_server_credentials(((private_key, certificate_chain),)) - server.add_secure_port(f"[::]:{port}", server_credentials) - logger.info("Starting secure gRPC server (TLS enabled)", extra={"port": port}) + server.add_secure_port(bind_target, server_credentials) + logger.info( + "Starting secure gRPC server (TLS enabled)", + extra={"host": host, "port": port}, + ) return "secure" if require_tls: @@ -56,11 +94,11 @@ def configure_grpc_server_port( extra={"port": port}, ) - server.add_insecure_port(f"[::]:{port}") + server.add_insecure_port(bind_target) logger.warning( "Starting insecure gRPC server (no TLS credentials found). " "Traffic will be unencrypted. Set NW_GRPC_TLS_CERT_PATH and NW_GRPC_TLS_KEY_PATH, " "or NW_GRPC_REQUIRE_TLS=true to fail startup in production.", - extra={"port": port}, + extra={"host": host, "port": port}, ) return "insecure" diff --git a/src/bindings/mcp_server/auth.py b/src/bindings/mcp_server/auth.py index 3a13265..b2b7bb8 100644 --- a/src/bindings/mcp_server/auth.py +++ b/src/bindings/mcp_server/auth.py @@ -79,7 +79,7 @@ def __init__(self) -> None: super().__init__( ( "MCP authentication is not configured. Set NW_MCP_API_KEY " - "(and optionally NW_MCP_JWT_SECRET), or set NW_MCP_AUTH_ENABLED=true " + "(and optionally NW_MCP_JWT_SECRET), or set NW_MCP_AUTH_DISABLED=true " "for local development only." ), status_code=503, @@ -116,7 +116,48 @@ def _bootstrap_mcp_auth_env() -> None: def mcp_auth_disabled() -> bool: - return _truthy(os.environ.get("NW_MCP_AUTH_ENABLED")) + """Return ``True`` when MCP authentication is disabled. + + The canonical flag is ``NW_MCP_AUTH_DISABLED`` (truthy disables auth), + matching ``NW_REST_AUTH_DISABLED`` / ``NW_GRPC_AUTH_DISABLED`` across the + other bindings. The default (unset) keeps authentication **enabled**. + + ``NW_MCP_AUTH_ENABLED`` is a deprecated legacy flag whose original + implementation inverted its own name — setting it to ``true`` *disabled* + authentication, the opposite of what an operator would expect. It is now + honored with its literal meaning (``false``/``0``/``no`` disables auth; + anything else keeps it enabled) and emits a deprecation warning. + ``NW_MCP_AUTH_DISABLED`` takes precedence when both are set. + """ + disabled = os.environ.get("NW_MCP_AUTH_DISABLED") + if disabled is not None and disabled.strip(): + return _truthy(disabled) + + legacy = os.environ.get("NW_MCP_AUTH_ENABLED") + if legacy is not None and legacy.strip(): + enabled = _truthy(legacy) + logger.warning( + "NW_MCP_AUTH_ENABLED is deprecated and its semantics have been " + "corrected; use NW_MCP_AUTH_DISABLED instead. Effective MCP auth " + "state: %s.", + "ENABLED" if enabled else "DISABLED", + ) + return not enabled + + return False + + +def log_effective_mcp_auth_state() -> None: + """Emit a single, explicit startup line describing the MCP auth posture.""" + disabled = mcp_auth_disabled() + logger.warning( + "MCP authentication is %s", + "DISABLED (local development only — do not use in production)" if disabled else "ENABLED", + extra={ + "auth_disabled": disabled, + "auth_configured": mcp_auth_configured(), + }, + ) def mcp_auth_configured() -> bool: diff --git a/src/bindings/mcp_server/server.py b/src/bindings/mcp_server/server.py index c404e0c..ba7d1eb 100644 --- a/src/bindings/mcp_server/server.py +++ b/src/bindings/mcp_server/server.py @@ -13,7 +13,11 @@ from typing import Any, Dict, List, Mapping, Optional, Tuple from bindings.factory import ConnectorFactory -from bindings.mcp_server.auth import McpAuthError, authenticate_mcp_request +from bindings.mcp_server.auth import ( + McpAuthError, + authenticate_mcp_request, + log_effective_mcp_auth_state, +) from node_wire_runtime.caller_identity import CallerIdentity from node_wire_runtime.policies.mcp_scope_policy import ( action_allowed_for_identity_scopes, @@ -466,6 +470,8 @@ async def _run_stdio_async(self) -> None: from mcp.server.stdio import stdio_server from mcp.server import NotificationOptions + log_effective_mcp_auth_state() + low = self._setup_lowlevel_server() async with stdio_server() as (read_stream, write_stream): @@ -562,6 +568,8 @@ async def _run_streamable_http_async(self) -> None: extra={"host": host, "port": port}, ) + log_effective_mcp_auth_state() + low = self._setup_lowlevel_server() session_manager = StreamableHTTPSessionManager(low, json_response=True) starlette_app = self._build_streamable_http_app(session_manager=session_manager, path=path) @@ -587,6 +595,8 @@ def run(self, transport: str = "stdio") -> None: if __name__ == "__main__": - # Simple demo runner that prints tool list and exits. + # Simple demo runner that emits the tool list as JSON to stdout and exits. + import sys + server = McpServer() - print(json.dumps(server.list_tools(), indent=2)) + sys.stdout.write(json.dumps(server.list_tools(), indent=2) + "\n") diff --git a/src/node_wire_http_generic/logic.py b/src/node_wire_http_generic/logic.py index b20dd53..6176768 100644 --- a/src/node_wire_http_generic/logic.py +++ b/src/node_wire_http_generic/logic.py @@ -4,8 +4,11 @@ # from __future__ import annotations +import asyncio +import ipaddress import logging import os +import socket from typing import Any from urllib.parse import urlsplit, urlunsplit @@ -13,11 +16,63 @@ from node_wire_runtime import BaseConnector, nw_action -from .schema import HttpRequestInput, HttpResponseOutput +from .schema import ( + HttpRequestInput, + HttpResponseOutput, + is_blocked_ip, + load_allowed_hosts, +) logger = logging.getLogger("connectors.http_generic") +class SsrfBlockedError(ValueError): + """Raised when an outbound HTTP target resolves to a blocked network destination.""" + + +async def _assert_safe_destination(url: str) -> None: + """Resolve the URL host and reject internal/blocked targets before connecting. + + Closes the gap between schema-time literal validation and the address the OS + actually dials: a hostname (or an alternate IP encoding) that resolves to + loopback, RFC1918, link-local or the cloud metadata service is rejected here, + immediately before the request is issued (mitigating DNS-name SSRF). When + ``NW_HTTP_GENERIC_ALLOWED_HOSTS`` is set, the host must additionally appear on + that egress allowlist. + """ + parts = urlsplit(url) + host = (parts.hostname or "").strip().lower().rstrip(".") + if not host: + raise SsrfBlockedError("url host is missing") + + allowed_hosts = load_allowed_hosts() + if allowed_hosts and host not in allowed_hosts: + raise SsrfBlockedError("url host is not on the egress allowlist") + + port = parts.port or (443 if parts.scheme == "https" else 80) + + # Resolve via the event loop's resolver (non-blocking) and validate every + # address the host maps to — a single hostname can return multiple records. + loop = asyncio.get_event_loop() + try: + infos = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM) + except socket.gaierror as exc: + raise SsrfBlockedError(f"url host could not be resolved: {host}") from exc + + if not infos: + raise SsrfBlockedError(f"url host could not be resolved: {host}") + + for info in infos: + sockaddr = info[4] + ip_str = sockaddr[0] + try: + ip_obj = ipaddress.ip_address(ip_str) + except ValueError: + raise SsrfBlockedError(f"url host resolved to an unparsable address: {ip_str}") + if is_blocked_ip(ip_obj): + raise SsrfBlockedError("url host resolves to a blocked network target") + + def _sanitize_url_for_log(raw_url: str) -> str: """ Remove query and fragment from URLs before logging to avoid leaking tokens/PII. @@ -63,9 +118,15 @@ async def request(self, params: HttpRequestInput, *, trace_id: str) -> HttpRespo }, ) + # Resolve-and-validate immediately before connecting. This is the + # authoritative SSRF gate; schema validation only sees the literal host. + await _assert_safe_destination(str(params.url)) + try: timeout = float(os.getenv("NW_TIMEOUT", "30.0")) - async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client: + async with httpx.AsyncClient( + timeout=timeout, trust_env=False, follow_redirects=False + ) as client: response = await client.request( method=params.method, url=str(params.url), diff --git a/src/node_wire_http_generic/schema.py b/src/node_wire_http_generic/schema.py index a365cac..b91e920 100644 --- a/src/node_wire_http_generic/schema.py +++ b/src/node_wire_http_generic/schema.py @@ -5,6 +5,8 @@ from __future__ import annotations import ipaddress +import os +import re from typing import Any, Dict, Literal, Optional from urllib.parse import urlsplit @@ -17,6 +19,22 @@ "metadata", } +# Optional egress allowlist (preferred control). Comma/space separated hostnames. +_ALLOWED_HOSTS_ENV = "NW_HTTP_GENERIC_ALLOWED_HOSTS" + + +def load_allowed_hosts() -> frozenset[str]: + """Return the configured egress allowlist of permitted destination hosts. + + Empty (unset) means "no allowlist configured" — the connector then falls + back to the denylist + resolved-IP range checks. When set, only the listed + hostnames may be reached. + """ + raw = os.environ.get(_ALLOWED_HOSTS_ENV) + if not raw or not raw.strip(): + return frozenset() + return frozenset(h.strip().lower().rstrip(".") for h in re.split(r"[\s,]+", raw) if h.strip()) + class HttpRequestInput(BaseModel): action: Literal["request"] = "request" @@ -48,21 +66,37 @@ def block_internal_targets(cls, value: HttpUrl) -> HttpUrl: return value -def _is_blocked_ip_literal(host: str) -> bool: - try: - ip_obj = ipaddress.ip_address(host) - except ValueError: - return False +def is_blocked_ip(ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + """Return True if an already-parsed address belongs to a blocked range. + + Shared by the schema-time literal check and the connection-time resolved-IP + check in :mod:`node_wire_http_generic.logic`, so both apply identical policy. + """ + # Normalize IPv4-mapped IPv6 (``::ffff:127.0.0.1``) to its IPv4 form so the + # range checks below catch loopback/private targets smuggled through IPv6. + if isinstance(ip_obj, ipaddress.IPv6Address) and ip_obj.ipv4_mapped is not None: + ip_obj = ip_obj.ipv4_mapped if ip_obj.is_loopback or ip_obj.is_private or ip_obj.is_link_local: return True if ip_obj.is_multicast or ip_obj.is_reserved or ip_obj.is_unspecified: return True - # Explicit cloud metadata target. - if str(ip_obj) == "169.254.169.254": + # Explicit cloud metadata target (IMDS). + if str(ip_obj) in ("169.254.169.254", "fd00:ec2::254"): return True return False +def _is_blocked_ip_literal(host: str) -> bool: + # Reject non-dotted-decimal numeric hosts (decimal ``2130706433``, octal + # ``0177.0.0.1``) that ``ipaddress`` rejects but the OS socket layer accepts. + # We only treat a host as an IP literal when it parses in canonical form. + try: + ip_obj = ipaddress.ip_address(host) + except ValueError: + return False + return is_blocked_ip(ip_obj) + + class HttpResponseOutput(BaseModel): status_code: int headers: Dict[str, str] diff --git a/src/node_wire_runtime/resilience.py b/src/node_wire_runtime/resilience.py index e9a5181..ce4053b 100644 --- a/src/node_wire_runtime/resilience.py +++ b/src/node_wire_runtime/resilience.py @@ -42,6 +42,57 @@ def _resolve_breaker( return breaker() if callable(breaker) else breaker +async def _run_through_breaker( + breaker: CircuitBreaker | Callable[[], CircuitBreaker], + fn: Callable[..., Awaitable[T]], + args: tuple[Any, ...], + kwargs: dict[str, Any], + trace_id: str, +) -> T: + """Drive a single async call through a pybreaker circuit breaker. + + pybreaker (1.x) exposes no clean asyncio-native public API: its only async + entry point (``CircuitBreaker.call_async``) is Tornado-based and internally + calls the very same ``CircuitBreakerState._handle_error`` / + ``_handle_success`` we use here. The counter and listener bookkeeping that + trips and resets the breaker lives inside those private wrappers (they also + touch ``_inc_counter`` / ``_state_storage``), so the public ``on_failure`` / + ``on_success`` cannot replace them without re-implementing pybreaker + internals. We therefore depend on these private methods deliberately, pin + ``pybreaker<2.0.0`` (see ``pyproject.toml``), and guard their continued + existence in ``tests/test_runtime_resilience.py``. The clean long-term fix is + migrating to an asyncio-native breaker (e.g. aiobreaker / purgatory). + """ + current_breaker = _resolve_breaker(breaker) + breaker_state = current_breaker.state + + if breaker_state.name == "open": + logger.error( + "Circuit breaker is OPEN; rejecting call", + extra={ + "trace_id": trace_id, + "component": "resilience", + "error": "circuit open", + }, + ) + raise CircuitBreakerError("Circuit breaker is open") + + breaker_state.before_call(fn, *args, **kwargs) + for listener in current_breaker.listeners: + listener.before_call(current_breaker, fn, *args, **kwargs) + + try: + result = await fn(*args, **kwargs) + except BaseException as exc: + # Record the failure (updates counters/listeners, may trip the breaker), + # then re-raise explicitly instead of relying on _handle_error's default + # reraise=True — keeps the control flow visible at this call site. + breaker_state._handle_error(exc, reraise=False) + raise + breaker_state._handle_success() + return result + + def with_resilience( breaker: CircuitBreaker | Callable[[], CircuitBreaker], max_attempts: int = 3, @@ -58,33 +109,6 @@ def decorator(fn: Callable[..., Awaitable[T]]) -> Callable[..., Coroutine[Any, A async def wrapper(*args: Any, **kwargs: Any) -> T: trace_id: str = kwargs.get("trace_id", "unknown-trace") - async def _call() -> T: - current_breaker = _resolve_breaker(breaker) - breaker_state = current_breaker.state - - if breaker_state.name == "open": - logger.error( - "Circuit breaker is OPEN; rejecting call", - extra={ - "trace_id": trace_id, - "component": "resilience", - "error": "circuit open", - }, - ) - raise CircuitBreakerError("Circuit breaker is open") - - breaker_state.before_call(fn, *args, **kwargs) - for listener in current_breaker.listeners: - listener.before_call(current_breaker, fn, *args, **kwargs) - - try: - result = await fn(*args, **kwargs) - except BaseException as exc: - breaker_state._handle_error(exc) - else: - breaker_state._handle_success() - return result - try: async for attempt in AsyncRetrying( retry=retry_if_exception_type(Exception), @@ -94,7 +118,7 @@ async def _call() -> T: ): with attempt: try: - return await _call() + return await _run_through_breaker(breaker, fn, args, kwargs, trace_id) except Exception as exc: # noqa: BLE001 mapped = ErrorMapper.resolve(exc) if mapped.category is not ErrorCategory.RETRYABLE: diff --git a/src/node_wire_salesforce/logic.py b/src/node_wire_salesforce/logic.py index a36ef02..1b89d94 100644 --- a/src/node_wire_salesforce/logic.py +++ b/src/node_wire_salesforce/logic.py @@ -1,6 +1,8 @@ from __future__ import annotations +import json import logging +import os from typing import Any, Dict, Optional, Tuple, Type, ClassVar import httpx @@ -148,7 +150,11 @@ async def _execute_rest( async with httpx.AsyncClient() as client: try: response = await client.request( - method, url, headers=headers, json=payload, timeout=30.0 + method, + url, + headers=headers, + json=payload, + timeout=float(os.getenv("NW_TIMEOUT", "30.0")), ) # Handle transient errors (5xx) by raising a retryable exception @@ -165,7 +171,17 @@ async def _execute_rest( if response.content: try: data = response.json() - except Exception: + except (ValueError, json.JSONDecodeError) as parse_exc: + logger.warning( + "Salesforce response was not valid JSON; using raw text", + extra={ + "trace_id": trace_id, + "connector_id": self.connector_id, + "method": method, + "path": path, + "error_type": type(parse_exc).__name__, + }, + ) data = {"text": response.text} obj_type = path.split("/")[0] diff --git a/src/node_wire_slack/logic.py b/src/node_wire_slack/logic.py index 3218a83..521e95a 100644 --- a/src/node_wire_slack/logic.py +++ b/src/node_wire_slack/logic.py @@ -47,7 +47,9 @@ _GET_UPLOAD_URL = "https://slack.com/api/files.getUploadURLExternal" _COMPLETE_UPLOAD_URL = "https://slack.com/api/files.completeUploadExternal" -_DEFAULT_TIMEOUT = 30.0 +# Configurable for consistency with other connectors (NW_TIMEOUT); NW_SLACK_TIMEOUT +# takes precedence when set. +_DEFAULT_TIMEOUT = float(os.getenv("NW_SLACK_TIMEOUT", os.getenv("NW_TIMEOUT", "30.0"))) _HARD_UPLOAD_LIMIT_MB = 100 _DEFAULT_UPLOAD_LIMIT_MB = 50 _CHANNEL_ID_RE = re.compile(r"^[CGDZ][A-Z0-9]{8,}$") @@ -242,7 +244,7 @@ async def _resolve_channel_id(token: str, target: str) -> str: return target if os.environ.get("NW_SLACK_SKIP_RESOLVE", "").lower() == "true": - logger.debug(f"Skipping channel resolution for {target} (NW_SLACK_SKIP_RESOLVE=true)") + logger.debug("Skipping channel resolution for %s (NW_SLACK_SKIP_RESOLVE=true)", target) return target prefix = target[0].upper() @@ -266,17 +268,21 @@ async def _resolve_channel_id(token: str, target: str) -> str: data = response.json() if data.get("ok"): resolved_id = data["channel"]["id"] - logger.debug(f"Resolved User ID {target} to DM channel {resolved_id}") + logger.debug("Resolved User ID %s to DM channel %s", target, resolved_id) return resolved_id # If Slack returns ok: false, fallback to the original ID logger.warning( - f"Failed to resolve User ID {target} to DM channel: {data.get('error')}" + "Failed to resolve User ID %s to DM channel: %s", target, data.get("error") ) return target except Exception as exc: # Catch network errors (ConnectError, etc.) and fallback to original ID - logger.warning(f"Network error resolving User ID {target} to DM channel: {exc}") + logger.warning( + "Network error resolving User ID %s to DM channel; falling back to original", + target, + extra={"error_type": type(exc).__name__, "error_message": str(exc)}, + ) return target return target diff --git a/tests/conftest.py b/tests/conftest.py index 5ccea29..a8bba11 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ """Shared pytest configuration. REST API tests default to ``NW_REST_AUTH_DISABLED=true`` so existing tests do not need -headers. MCP tests default to ``NW_MCP_AUTH_ENABLED=true`` for the same reason. +headers. MCP tests default to ``NW_MCP_AUTH_DISABLED=true`` for the same reason. Tests that assert authentication behavior override these env vars. """ @@ -65,7 +65,7 @@ def _preload_connector_logic_modules() -> None: @pytest.fixture(autouse=True) def _rest_auth_disabled_for_tests(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("NW_REST_AUTH_DISABLED", "true") - monkeypatch.setenv("NW_MCP_AUTH_ENABLED", "true") + monkeypatch.setenv("NW_MCP_AUTH_DISABLED", "true") monkeypatch.setenv("NW_MCP_SCOPE_POLICY_DEFAULT", "allow") monkeypatch.setenv("NW_JWT_AUDIENCE", "node-wire-test") monkeypatch.setenv("NW_JWT_ISSUER", "node-wire-test-issuer") diff --git a/tests/test_connectors_io.py b/tests/test_connectors_io.py index 5b9a3d2..277006f 100644 --- a/tests/test_connectors_io.py +++ b/tests/test_connectors_io.py @@ -7,7 +7,8 @@ from __future__ import annotations import asyncio -from unittest.mock import ANY, MagicMock, patch +import socket +from unittest.mock import ANY, AsyncMock, MagicMock, patch import httpx import pytest @@ -129,8 +130,14 @@ async def request(self, **kwargs: object) -> MagicMock: return mock_resp async def _run() -> None: - with patch( - "node_wire_http_generic.logic.httpx.AsyncClient", return_value=_FakeAsyncClient() + with ( + patch( + "node_wire_http_generic.logic.httpx.AsyncClient", return_value=_FakeAsyncClient() + ), + patch( + "node_wire_http_generic.logic._assert_safe_destination", + new=AsyncMock(return_value=None), + ), ): c = HttpGenericConnector() inp = HttpRequestInput(url="http://example.com/path", method="GET") @@ -175,6 +182,72 @@ def test_http_request_input_allows_public_url() -> None: assert str(parsed.url) == "https://example.com/path?q=1" +# --------------------------------------------------------------------------- +# H-2 regression: SSRF resolve-and-validate at connection time. +# --------------------------------------------------------------------------- + + +def _fake_getaddrinfo(ip: str): + """Return an async stand-in for ``loop.getaddrinfo`` that resolves to ``ip``.""" + + async def _resolver(host, port, *args, **kwargs): + family = socket.AF_INET6 if ":" in ip else socket.AF_INET + return [(family, socket.SOCK_STREAM, 6, "", (ip, port))] + + return _resolver + + +@pytest.mark.parametrize( + "resolved_ip", + [ + "127.0.0.1", # loopback via DNS name + "10.1.2.3", # RFC1918 + "169.254.169.254", # cloud metadata + "::ffff:127.0.0.1", # IPv4-mapped IPv6 loopback + "::1", # IPv6 loopback + ], +) +def test_http_blocks_dns_name_resolving_to_internal_ip(resolved_ip: str) -> None: + from node_wire_http_generic.logic import SsrfBlockedError, _assert_safe_destination + + async def _run() -> None: + loop = asyncio.get_event_loop() + with patch.object(loop, "getaddrinfo", new=_fake_getaddrinfo(resolved_ip)): + with pytest.raises(SsrfBlockedError): + # A perfectly public-looking hostname that resolves internally. + await _assert_safe_destination("http://totally-public.example.com/x") + + asyncio.run(_run()) + + +def test_http_allows_public_resolved_ip() -> None: + from node_wire_http_generic.logic import _assert_safe_destination + + async def _run() -> None: + loop = asyncio.get_event_loop() + with patch.object(loop, "getaddrinfo", new=_fake_getaddrinfo("93.184.216.34")): + # Must not raise. + await _assert_safe_destination("https://example.com/path") + + asyncio.run(_run()) + + +def test_http_egress_allowlist_blocks_unlisted_host(monkeypatch: pytest.MonkeyPatch) -> None: + from node_wire_http_generic.logic import SsrfBlockedError, _assert_safe_destination + + monkeypatch.setenv("NW_HTTP_GENERIC_ALLOWED_HOSTS", "api.allowed.example.com") + + async def _run() -> None: + loop = asyncio.get_event_loop() + with patch.object(loop, "getaddrinfo", new=_fake_getaddrinfo("93.184.216.34")): + with pytest.raises(SsrfBlockedError): + await _assert_safe_destination("https://evil.example.com/x") + # Listed host with a public IP is allowed. + await _assert_safe_destination("https://api.allowed.example.com/x") + + asyncio.run(_run()) + + def test_http_generic_logs_sanitized_url() -> None: mock_resp = MagicMock() mock_resp.status_code = 200 @@ -196,6 +269,10 @@ async def _run() -> None: patch( "node_wire_http_generic.logic.httpx.AsyncClient", return_value=_FakeAsyncClient() ), + patch( + "node_wire_http_generic.logic._assert_safe_destination", + new=AsyncMock(return_value=None), + ), patch("node_wire_http_generic.logic.logger.info") as mocked_info, ): c = HttpGenericConnector() diff --git a/tests/test_mcp_auth.py b/tests/test_mcp_auth.py index a28e784..6a6e5c4 100644 --- a/tests/test_mcp_auth.py +++ b/tests/test_mcp_auth.py @@ -10,6 +10,7 @@ McpAuthInvalidError, McpAuthRequiredError, authenticate_mcp_request, + mcp_auth_disabled, ) from bindings.mcp_server.server import McpServer from tests.jwt_test_helpers import mint_test_jwt @@ -28,7 +29,7 @@ def _mcp_auth_clear_allowlist_from_host_env(monkeypatch: pytest.MonkeyPatch) -> def test_mcp_auth_missing_token_returns_401(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) monkeypatch.setenv("NW_MCP_API_KEY", "unit-test-secret") monkeypatch.delenv("NW_MCP_JWT_SECRET", raising=False) @@ -39,7 +40,7 @@ def test_mcp_auth_missing_token_returns_401(monkeypatch: pytest.MonkeyPatch) -> def test_mcp_auth_invalid_token_returns_403(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) monkeypatch.setenv("NW_MCP_API_KEY", "unit-test-secret") monkeypatch.delenv("NW_MCP_JWT_SECRET", raising=False) @@ -50,7 +51,7 @@ def test_mcp_auth_invalid_token_returns_403(monkeypatch: pytest.MonkeyPatch) -> def test_mcp_auth_valid_token_allows_tools_list(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) monkeypatch.setenv("NW_MCP_API_KEY", "unit-test-secret") monkeypatch.delenv("NW_MCP_JWT_SECRET", raising=False) @@ -64,7 +65,7 @@ def test_mcp_auth_valid_token_allows_tools_list(monkeypatch: pytest.MonkeyPatch) @pytest.mark.asyncio async def test_mcp_authz_denies_tool_without_scope(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) monkeypatch.delenv("NW_MCP_API_KEY", raising=False) monkeypatch.setenv("NW_MCP_JWT_SECRET", "jwt-secret") monkeypatch.setenv( @@ -100,7 +101,7 @@ async def test_mcp_authz_denies_tool_without_scope(monkeypatch: pytest.MonkeyPat async def test_mcp_execution_passes_principal_and_tenant( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) monkeypatch.delenv("NW_MCP_API_KEY", raising=False) monkeypatch.setenv("NW_MCP_JWT_SECRET", "jwt-secret") monkeypatch.delenv("NW_MCP_ACTION_SCOPE_MAP_JSON", raising=False) @@ -149,7 +150,7 @@ async def fake_run(raw_input, *, principal=None, tenant_id=None, scopes=None): def test_mcp_api_key_scopes_filter_tools_list(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) monkeypatch.setenv("NW_MCP_API_KEY", "unit-test-secret") monkeypatch.setenv( "NW_MCP_ACTION_SCOPE_MAP_JSON", @@ -167,7 +168,7 @@ def test_mcp_api_key_scopes_filter_tools_list(monkeypatch: pytest.MonkeyPatch) - def test_mcp_jwt_scopes_filter_tools_list(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) monkeypatch.delenv("NW_MCP_API_KEY", raising=False) monkeypatch.setenv("NW_MCP_JWT_SECRET", "jwt-secret") monkeypatch.setenv( @@ -189,7 +190,7 @@ def test_mcp_jwt_scopes_filter_tools_list(monkeypatch: pytest.MonkeyPatch) -> No async def test_mcp_default_deny_fallback_scope_invokes_tool( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) monkeypatch.delenv("NW_MCP_API_KEY", raising=False) monkeypatch.setenv("NW_MCP_JWT_SECRET", "jwt-secret") monkeypatch.delenv("NW_MCP_ACTION_SCOPE_MAP_JSON", raising=False) @@ -237,7 +238,7 @@ async def fake_run(raw_input, *, principal=None, tenant_id=None, scopes=None): async def test_mcp_default_deny_denies_without_fallback_scope( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) monkeypatch.delenv("NW_MCP_API_KEY", raising=False) monkeypatch.setenv("NW_MCP_JWT_SECRET", "jwt-secret") monkeypatch.delenv("NW_MCP_ACTION_SCOPE_MAP_JSON", raising=False) @@ -268,7 +269,7 @@ async def test_mcp_default_deny_denies_without_fallback_scope( def test_mcp_api_key_explicit_star_scope_lists_tool(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) monkeypatch.setenv("NW_MCP_API_KEY", "unit-test-secret") monkeypatch.setenv( "NW_MCP_ACTION_SCOPE_MAP_JSON", @@ -293,7 +294,7 @@ async def handle_request(self, scope, receive, send): def test_streamable_http_edge_auth_rejects_missing_token(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) monkeypatch.setenv("NW_MCP_API_KEY", "unit-test-secret") monkeypatch.delenv("NW_MCP_JWT_SECRET", raising=False) @@ -310,7 +311,7 @@ def test_streamable_http_edge_auth_rejects_missing_token(monkeypatch: pytest.Mon def test_streamable_http_edge_auth_rejects_invalid_token(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) monkeypatch.setenv("NW_MCP_API_KEY", "unit-test-secret") monkeypatch.delenv("NW_MCP_JWT_SECRET", raising=False) @@ -331,7 +332,7 @@ def test_streamable_http_edge_auth_rejects_invalid_token(monkeypatch: pytest.Mon def test_streamable_http_edge_auth_accepts_valid_token(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) monkeypatch.setenv("NW_MCP_API_KEY", "unit-test-secret") monkeypatch.delenv("NW_MCP_JWT_SECRET", raising=False) @@ -355,7 +356,7 @@ def test_streamable_http_edge_auth_accepts_valid_token(monkeypatch: pytest.Monke async def test_streamable_http_identity_context_is_used_by_mcp_server( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) monkeypatch.setenv("NW_MCP_API_KEY", "unit-test-secret") monkeypatch.delenv("NW_MCP_JWT_SECRET", raising=False) @@ -373,3 +374,58 @@ async def test_streamable_http_identity_context_is_used_by_mcp_server( assert resolved is not None assert resolved.principal == "api-key-user" + + +# --------------------------------------------------------------------------- +# H-1 regression: the MCP auth flag must not silently disable authentication. +# --------------------------------------------------------------------------- + + +def test_mcp_auth_default_is_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + """With neither flag set, MCP authentication is enabled (fail-closed).""" + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + assert mcp_auth_disabled() is False + + +def test_legacy_auth_enabled_true_keeps_auth_enforced( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression for H-1: NW_MCP_AUTH_ENABLED=true must ENFORCE auth, not disable it. + + The legacy flag's name says "enabled"; an operator setting it to ``true`` + expects authentication on. The previous implementation inverted this and + disabled auth. This test pins the corrected behaviour. + """ + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) + monkeypatch.setenv("NW_MCP_AUTH_ENABLED", "true") + monkeypatch.setenv("NW_MCP_API_KEY", "unit-test-secret") + monkeypatch.delenv("NW_MCP_JWT_SECRET", raising=False) + + assert mcp_auth_disabled() is False + # A request with no credentials is rejected rather than waved through. + with pytest.raises(McpAuthRequiredError): + authenticate_mcp_request() + + +def test_legacy_auth_enabled_false_disables_auth(monkeypatch: pytest.MonkeyPatch) -> None: + """NW_MCP_AUTH_ENABLED=false honours its literal meaning and disables auth.""" + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) + monkeypatch.setenv("NW_MCP_AUTH_ENABLED", "false") + assert mcp_auth_disabled() is True + + +def test_canonical_disable_flag_disables_auth(monkeypatch: pytest.MonkeyPatch) -> None: + """NW_MCP_AUTH_DISABLED matches the REST/gRPC bindings and disables auth.""" + monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.setenv("NW_MCP_AUTH_DISABLED", "true") + assert mcp_auth_disabled() is True + # Disabled gate returns no identity instead of raising. + assert authenticate_mcp_request() is None + + +def test_canonical_disable_flag_takes_precedence(monkeypatch: pytest.MonkeyPatch) -> None: + """When both flags are set, NW_MCP_AUTH_DISABLED wins.""" + monkeypatch.setenv("NW_MCP_AUTH_DISABLED", "false") + monkeypatch.setenv("NW_MCP_AUTH_ENABLED", "false") # legacy would say "disable" + assert mcp_auth_disabled() is False diff --git a/tests/test_network_bindings.py b/tests/test_network_bindings.py index faa33c1..925b436 100644 --- a/tests/test_network_bindings.py +++ b/tests/test_network_bindings.py @@ -4,7 +4,11 @@ import pytest -from bindings.grpc_server.tls_config import configure_grpc_server_port +from bindings.grpc_server.tls_config import ( + configure_grpc_server_port, + is_public_bind_host as grpc_is_public_bind_host, + resolve_grpc_host, +) from bindings.mcp_server.server import ( McpServer, is_public_bind_host, @@ -87,10 +91,56 @@ def test_grpc_configure_insecure_when_tls_absent() -> None: require_tls=False, ) assert mode == "insecure" - grpc_server.add_insecure_port.assert_called_once_with("[::]:50051") + # Default bind host is now loopback (parity with MCP), not all-interfaces. + grpc_server.add_insecure_port.assert_called_once_with("127.0.0.1:50051") grpc_server.add_secure_port.assert_not_called() +def test_resolve_grpc_host_defaults_to_localhost(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("NW_GRPC_HOST", raising=False) + assert resolve_grpc_host() == "127.0.0.1" + + +def test_resolve_grpc_host_respects_explicit_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NW_GRPC_HOST", "::") + assert resolve_grpc_host() == "::" + + +def test_grpc_is_public_bind_host() -> None: + assert grpc_is_public_bind_host("0.0.0.0") + assert grpc_is_public_bind_host("::") + assert not grpc_is_public_bind_host("127.0.0.1") + + +def test_grpc_ipv6_host_is_bracketed(monkeypatch: pytest.MonkeyPatch) -> None: + grpc_server = MagicMock() + configure_grpc_server_port( + grpc_server, + port=50051, + host="::", + cert_path=None, + key_path=None, + require_tls=False, + ) + grpc_server.add_insecure_port.assert_called_once_with("[::]:50051") + + +def test_grpc_public_bind_logs_warning( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + grpc_server = MagicMock() + with caplog.at_level("WARNING", logger="bindings.grpc_server"): + configure_grpc_server_port( + grpc_server, + port=50051, + host="0.0.0.0", + cert_path=None, + key_path=None, + require_tls=False, + ) + assert any("binding to all interfaces" in record.message for record in caplog.records) + + def test_grpc_require_tls_raises_without_creds() -> None: grpc_server = MagicMock() with pytest.raises(ValueError, match="NW_GRPC_REQUIRE_TLS"): diff --git a/tests/test_runtime_resilience.py b/tests/test_runtime_resilience.py index ad9e864..764bd28 100644 --- a/tests/test_runtime_resilience.py +++ b/tests/test_runtime_resilience.py @@ -192,3 +192,38 @@ async def fail(*, trace_id: str = "t") -> str: with pytest.raises(CircuitBreakerError): asyncio.run(fail(trace_id="t3")) + + +def test_pybreaker_private_contract_still_present() -> None: + """Guard for Q-2: the resilience adapter depends on pybreaker private methods. + + pybreaker exposes no clean asyncio public API, so ``_run_through_breaker`` + deliberately uses ``CircuitBreakerState._handle_error`` / ``_handle_success`` + and pins ``pybreaker<2.0.0``. If a future upgrade removes these, this test + fails loudly in CI instead of the breaker silently mis-counting failures. + """ + from pybreaker import CircuitBreakerState + + assert hasattr(CircuitBreakerState, "_handle_error") + assert hasattr(CircuitBreakerState, "_handle_success") + + +def test_with_resilience_success_resets_failure_counter() -> None: + """A successful call after a failure resets the breaker's failure counter.""" + breaker = CircuitBreaker(fail_max=3, reset_timeout=30) + state = {"fail": True} + + @with_resilience(breaker, max_attempts=1) + async def sometimes(*, trace_id: str = "t") -> str: + if state["fail"]: + raise RetryableTestError("boom") + return "ok" + + with pytest.raises(RetryableTestError): + asyncio.run(sometimes(trace_id="t1")) + assert breaker.fail_counter == 1 + + state["fail"] = False + assert asyncio.run(sometimes(trace_id="t2")) == "ok" + assert breaker.fail_counter == 0 + assert breaker.state.name == "closed" diff --git a/tests/test_slack_connector.py b/tests/test_slack_connector.py index ee738e6..e629a0b 100644 --- a/tests/test_slack_connector.py +++ b/tests/test_slack_connector.py @@ -483,3 +483,70 @@ async def test_upload_file_invalid_resolved_channel_returns_business_error() -> assert result.error_code == "SLACK_UPLOAD_ERROR" assert "Could not resolve '#general' to a valid Slack channel ID" in result.message get_upload_url.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_resolve_channel_id_network_error_falls_back_and_logs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Q-4: a network error resolving a User ID falls back to the target and logs structured context.""" + import httpx + + from node_wire_slack.logic import _resolve_channel_id + + # The repo .env sets NW_SLACK_SKIP_RESOLVE=true; clear it so the resolution + # (and its error path) actually runs when other tests have loaded dotenv. + monkeypatch.delenv("NW_SLACK_SKIP_RESOLVE", raising=False) + + class _RaisingClient: + def __init__(self, *args: Any, **kwargs: Any) -> None: ... + + async def __aenter__(self) -> "_RaisingClient": + return self + + async def __aexit__(self, *args: Any) -> None: + return None + + async def post(self, *args: Any, **kwargs: Any) -> Any: + raise httpx.ConnectError("connection refused") + + # Attach a handler directly to the connector logger so capture is independent + # of global logging state mutated by other test modules in the full suite. + slack_logger = logging.getLogger("connectors.slack") + records: list[logging.LogRecord] = [] + + class _Capture(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + handler = _Capture(level=logging.WARNING) + prev_level = slack_logger.level + slack_logger.addHandler(handler) + slack_logger.setLevel(logging.WARNING) + try: + with patch("node_wire_slack.logic.httpx.AsyncClient", new=_RaisingClient): + resolved = await _resolve_channel_id("xoxb-fake-token", "U12345678") + finally: + slack_logger.removeHandler(handler) + slack_logger.setLevel(prev_level) + + assert resolved == "U12345678" # fell back to original target + warnings = [r for r in records if "Network error resolving" in r.getMessage()] + assert warnings, "expected a structured network-error warning" + assert getattr(warnings[0], "error_type", None) == "ConnectError" + + +def test_default_timeout_honors_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Q-7: _DEFAULT_TIMEOUT is configurable via NW_SLACK_TIMEOUT / NW_TIMEOUT.""" + import importlib + + import node_wire_slack.logic as slack_logic + + monkeypatch.setenv("NW_SLACK_TIMEOUT", "12.5") + try: + reloaded = importlib.reload(slack_logic) + assert reloaded._DEFAULT_TIMEOUT == 12.5 + finally: + # Restore the module to its default-env state for the rest of the suite. + monkeypatch.delenv("NW_SLACK_TIMEOUT", raising=False) + importlib.reload(slack_logic) diff --git a/uv.lock b/uv.lock index 019e77e..471546e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15'", @@ -2166,7 +2166,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.45b0" }, { name = "opentelemetry-sdk", specifier = ">=1.24.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0.0" }, - { name = "pybreaker", specifier = ">=1.0.0" }, + { name = "pybreaker", specifier = ">=1.0.0,<2.0.0" }, { name = "pydantic", specifier = ">=2.6.0,<3.0.0" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.8.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, From 11f3eab2e997676c86a5a868fe531576553a2dad Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Fri, 26 Jun 2026 07:39:44 -0700 Subject: [PATCH 03/20] Docs corrected and updated --- docs/configuration.md | 4 +++- docs/connectors.md | 16 ++++++++-------- docs/google_drive_connector.md | 10 +++++----- docs/local-packages-to-images.md | 6 +++++- docs/mcp-servers.md | 4 ++-- docs/packaging.md | 10 ++++++++-- docs/quality-security-gates.md | 2 +- src/node_wire_fhir_cerner/README.md | 21 +++++++++++++++------ src/node_wire_fhir_epic/README.md | 14 ++++++-------- src/node_wire_google_drive/README.md | 4 ++-- 10 files changed, 55 insertions(+), 36 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 3f01f7e..c728866 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -41,8 +41,10 @@ copy sample.env .env | `PORT` | Port for the REST API | `8000` | | `NW_MCP_TRANSPORT` | MCP transport mode (`stdio` or `streamable-http`) | `stdio` | | `NW_MCP_HOST` | MCP streamable-http bind address | `127.0.0.1` | -| `NW_MCP_PORT` | Port for streamable-http MCP | `8080` | +| `NW_MCP_PORT` | Port for streamable-http MCP | `8081` | | `NW_REST_AUTH_DISABLED` | Disable REST API authentication (local dev only) | `false` | +| `NW_MCP_AUTH_DISABLED` | Disable MCP authentication (local dev only); default (unset) enforces auth. The legacy `NW_MCP_AUTH_ENABLED` flag is deprecated. | `false` | +| `NW_MCP_API_KEY` | Shared secret for MCP API-key auth (set in production) | _(unset)_ | | `NW_MCP_SCOPE_POLICY_DEFAULT` | Scope policy when action map has no entry: `deny` (conventional `mcp:.`) or `allow` (map-only) | `deny` | | `NW_MCP_SCOPE_POLICY_STRICT` | Fail startup if scope policy would be disabled (`allow` + empty map) | `false` | | `NW_GRPC_API_KEY` | Shared secret for gRPC metadata (`authorization` or `x-api-key`) | _(unset)_ | diff --git a/docs/connectors.md b/docs/connectors.md index 50a92ba..a0f55e8 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -213,7 +213,7 @@ class GoogleDriveConnector(BaseConnector): raw=raw, description=f"Successfully executed {action_name}", ) - +``` ## Connector Authentication @@ -268,7 +268,6 @@ connectors: ``` --- -``` Key points: - **`connector_id`** — unique string; used for routing, config, and registry lookup. @@ -492,12 +491,13 @@ MCP tool names: **`.`** (e.g. `fhir_epic.read_patient`). S ## Adding a new connector (checklist) -3. In `logic.py`: subclass `BaseConnector`, set `connector_id` and `output_model`, then add `@nw_action` methods or wire `action_specs`. -4. **Authentication**: Delegate all header construction to **`self.get_auth_headers()`**. Do not hardcode secret lookups or IdP handshakes and ensure sensitive fields are removed from your `input_schema`. -5. For SDK-style connectors, add an `action_spec.py` (or similar) with `SdkActionSpec` entries and use **`execute_spec_in_thread`** when the vendor client is blocking. -6. Optionally add `error_map` and/or `registration.py` for custom exception handling. -7. Add the connector to **`config/connectors.yaml`** with `enabled: true`, the desired `exposed_via` protocols, and an **`auth:`** block. -8. That's it — `auto_register()` handles the rest. No factory branch required. +1. Create the package directory `src/node_wire_/` with `schema.py` (Pydantic input/output models) and register the entry point under `[project.entry-points."node_wire.connectors"]`. +2. In `logic.py`: subclass `BaseConnector`, set `connector_id` and `output_model`, then add `@nw_action` methods or wire `action_specs`. +3. **Authentication**: Delegate all header construction to **`self.get_auth_headers()`**. Do not hardcode secret lookups or IdP handshakes and ensure sensitive fields are removed from your `input_schema`. +4. For SDK-style connectors, add an `action_spec.py` (or similar) with `SdkActionSpec` entries and use **`execute_spec_in_thread`** when the vendor client is blocking. +5. Optionally add `error_map` and/or `registration.py` for custom exception handling. +6. Add the connector to **`config/connectors.yaml`** with `enabled: true`, the desired `exposed_via` protocols, and an **`auth:`** block. +7. That's it — `auto_register()` handles the rest. No factory branch required. --- diff --git a/docs/google_drive_connector.md b/docs/google_drive_connector.md index 08c9e9d..b887852 100644 --- a/docs/google_drive_connector.md +++ b/docs/google_drive_connector.md @@ -6,10 +6,10 @@ SPDX-License-Identifier: Apache-2.0 # Google Drive Connector -This document covers the Google Drive connector under `connectors/google_drive` in two parts: +This document covers the Google Drive connector under `src/node_wire_google_drive` in two parts: 1. **[Google Drive service account setup](#google-drive-service-account-setup)** — Create a GCP service account, enable the Drive API, configure `.env`, share a folder, and verify connectivity. -2. **[REST API reference](#rest-api-reference)** — The `execute` action, all seven operations, request/response shapes, and the platform error taxonomy. +2. **[REST API reference](#rest-api-reference)** — All seven operations (one REST route each), request/response shapes, and the platform error taxonomy. For **MCP** (e.g. ToolHive), tools are named `google_drive.` from the connector manifest (e.g. `google_drive.files.upload`). End-to-end agent setup is documented in [docs/toolhive_agent_scenario.md](toolhive_agent_scenario.md). @@ -156,7 +156,7 @@ Start the platform and test the connection with a quick file list: python -m bindings_entrypoint # In another terminal, list files visible to the service account -curl -X POST http://localhost:8000/connectors/google_drive/execute \ +curl -X POST http://localhost:8000/connectors/google_drive/files.list \ -H "Content-Type: application/json" \ -d '{"action": "files.list", "page_size": 5}' ``` @@ -193,14 +193,14 @@ To upload a test file, use the request body documented under [files.upload](#fil ## REST API reference -The connector exposes a single action `execute` with a discriminated-union payload. The `action` field decides which Google Drive operation runs. All responses share a common output shape and error taxonomy enforced by the runtime. +The connector exposes **one REST route per operation** (`POST /connectors/google_drive/`, e.g. `files.list`). The `action` field on the request body selects which Google Drive operation runs for `BaseConnector` dispatch. All responses share a common output shape and error taxonomy enforced by the runtime. ### Operations overview All requests go through: - Connector ID: `google_drive` -- REST endpoint: `POST /connectors/google_drive/execute` +- REST endpoint: `POST /connectors/google_drive/` (e.g. `POST /connectors/google_drive/files.list`) Each operation uses `action` as a discriminator: diff --git a/docs/local-packages-to-images.md b/docs/local-packages-to-images.md index b66361a..faa3e13 100644 --- a/docs/local-packages-to-images.md +++ b/docs/local-packages-to-images.md @@ -88,6 +88,8 @@ This builds: - `nw-smartonfhir-cerner` - `nw-smtp` - `nw-stripe` +- `nw-salesforce` +- `nw-slack` ### Build one image manually @@ -108,7 +110,9 @@ Each Dockerfile expects specific wheel files to exist in `dist/`: | `docker/fhir-epic/Dockerfile` | `packages/runtime/dist/*.whl`, `packages/connectors/fhir_epic/dist/*.whl` | | `docker/fhir-cerner/Dockerfile` | `packages/runtime/dist/*.whl`, `packages/connectors/fhir_cerner/dist/*.whl` | | `docker/stripe/Dockerfile` | `packages/runtime/dist/*.whl`, `packages/connectors/stripe/dist/*.whl` | -| `Dockerfile` (unified MCP server) | runtime + all connector wheels (`http_generic`, `stripe`, `smtp`, `google_drive`, `fhir_epic`, `fhir_cerner`) | +| `docker/salesforce/Dockerfile` | `packages/runtime/dist/*.whl`, `packages/connectors/salesforce/dist/*.whl` | +| `docker/slack/Dockerfile` | `packages/runtime/dist/*.whl`, `packages/connectors/slack/dist/*.whl` | +| `Dockerfile` (unified MCP server) | runtime + connector wheels (`http_generic`, `stripe`, `smtp`, `slack`, `google_drive`, `fhir_epic`, `fhir_cerner`) | --- diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index 2b8ed24..232fe20 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -544,10 +544,10 @@ thv run --name nw-stripe --transport stdio \ # Salesforce thv run --name nw-salesforce --transport stdio \ --secret SALESFORCE_INSTANCE_URL,target=SALESFORCE_INSTANCE_URL \ + --secret SALESFORCE_TOKEN_URL,target=SALESFORCE_TOKEN_URL \ --secret SALESFORCE_CLIENT_ID,target=SALESFORCE_CLIENT_ID \ --secret SALESFORCE_CLIENT_SECRET,target=SALESFORCE_CLIENT_SECRET \ - --secret SALESFORCE_USERNAME,target=SALESFORCE_USERNAME \ - --secret SALESFORCE_PASSWORD,target=SALESFORCE_PASSWORD \ + --secret SALESFORCE_REFRESH_TOKEN,target=SALESFORCE_REFRESH_TOKEN \ nw-salesforce:latest # Slack diff --git a/docs/packaging.md b/docs/packaging.md index a9c176d..a35933d 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -6,7 +6,7 @@ SPDX-License-Identifier: Apache-2.0 # Packaging & Publishing -Node Wire ships as **seven independent PyPI packages** built from a single monorepo. All wheels are binary-only (Cython-compiled `.so`/`.pyd` files) — no `.py` source is included in any published wheel. +Node Wire ships as **nine independent PyPI packages** (the runtime plus eight connectors) built from a single monorepo. All wheels are binary-only (Cython-compiled `.so`/`.pyd` files) — no `.py` source is included in any published wheel. --- @@ -19,11 +19,15 @@ Node Wire ships as **seven independent PyPI packages** built from a single monor | `node-wire-fhir-epic` | `src/node_wire_fhir_epic/` | `fhir_epic` | | `node-wire-google-drive` | `src/node_wire_google_drive/` | `google_drive` | | `node-wire-http-generic` | `src/node_wire_http_generic/` | `http_generic` | +| `node-wire-salesforce` | `src/node_wire_salesforce/` | `salesforce` | +| `node-wire-slack` | `src/node_wire_slack/` | `slack` | | `node-wire-smtp` | `src/node_wire_smtp/` | `smtp` | | `node-wire-stripe` | `src/node_wire_stripe/` | `stripe` | Each connector's `pyproject.toml` lives at `packages/connectors//pyproject.toml`; the runtime's is at `packages/runtime/pyproject.toml`. +> **Note:** `salesforce` and `slack` build locally via `scripts/build-packages.sh` but are **not yet in the `.github/workflows/publish.yml` allowlist** (which currently publishes seven packages), so they do not auto-publish through that workflow. + --- ## Python package build lifecycle @@ -36,7 +40,7 @@ Prerequisites: `pip install build cython wheel` (and a usable `python` on the ho bash scripts/build-packages.sh ``` -Default mode builds each of the **seven** known package paths (see inventory above): `python -m build --wheel` on the **host**, then again inside **Docker** (`python:3.12-slim`) so you get Linux-tagged wheels suitable for containers. **Docker must be installed and the daemon running.** After each package, the script scans every produced wheel and fails if any `.py` file appears inside the archive. +Default mode builds each of the **nine** known package paths (see inventory above): `python -m build --wheel` on the **host**, then again inside **Docker** (`python:3.12-slim`) so you get Linux-tagged wheels suitable for containers. **Docker must be installed and the daemon running.** After each package, the script scans every produced wheel and fails if any `.py` file appears inside the archive. ### Artifact layout and safe command usage @@ -202,6 +206,8 @@ docker build -f docker/google-drive/Dockerfile -t nw-google-drive . docker build -f docker/fhir-epic/Dockerfile -t nw-smartonfhir-epic . docker build -f docker/fhir-cerner/Dockerfile -t nw-smartonfhir-cerner . docker build -f docker/stripe/Dockerfile -t nw-stripe . +docker build -f docker/salesforce/Dockerfile -t nw-salesforce . +docker build -f docker/slack/Dockerfile -t nw-slack . ``` For compose and ToolHive registration see `docs/mcp-servers.md`. diff --git a/docs/quality-security-gates.md b/docs/quality-security-gates.md index 09dd815..b78b190 100644 --- a/docs/quality-security-gates.md +++ b/docs/quality-security-gates.md @@ -38,7 +38,7 @@ Configure branch protection so pull requests cannot merge unless all required ch - PR and push-to-main scanning runs in `.github/workflows/security-pr.yml`. - Release-time scanning remains in `.github/workflows/publish.yml` as defense in depth. -- `pip-audit --fail-on HIGH` is the vulnerability gate threshold. +- The PR/push gate (`security-pr.yml`) runs `pip-audit` with no `--fail-on` threshold, so it **blocks on any vulnerability**. The release workflow (`publish.yml`) uses `pip-audit --fail-on HIGH` as defense in depth. - Scheduled scans catch newly disclosed CVEs even when code does not change. **Monorepo install note:** Connector packages under `packages/connectors/*` declare `node-wire-runtime>=0.1.0` as a normal PyPI dependency name. The security workflow installs `packages/runtime` from the checkout **together with** each matrix package (`pip install packages/runtime ""`) so `pip` can resolve `node-wire-runtime` without requiring a published wheel on PyPI. Locally, mirror that when auditing a single connector: `pip install packages/runtime packages/connectors/`. diff --git a/src/node_wire_fhir_cerner/README.md b/src/node_wire_fhir_cerner/README.md index 95cc58c..0532b0c 100644 --- a/src/node_wire_fhir_cerner/README.md +++ b/src/node_wire_fhir_cerner/README.md @@ -9,9 +9,8 @@ SPDX-License-Identifier: Apache-2.0 > **Platform:** Node Wire > **Standard:** FHIR R4 > **Auth Method:** SMART Backend Services — `private_key_jwt` (RS384) -> **Actions:** `read_patient` · `search_encounter` · `create_document_reference` · `search_document_reference` +> **Actions:** `read_patient` · `search_patients` · `search_encounter` · `create_document_reference` · `search_document_reference` > **Source:** `src/node_wire_fhir_cerner/` -> **Test Collection:** `postman_fhir_cerner_collection.json` --- @@ -23,8 +22,7 @@ The FHIR Cerner connector is designed to interface with Cerner EHR systems using A single configuration entry in `connectors.yaml` exposes multiple distinct operations (actions), sharing the same authentication state and underlying infrastructure: -- **`FhirCernerConnector`**: The central controller that encapsulates all shared logic, authentication flows, and specific implementation methods for each action. -- **`_FhirCernerAction`**: A lightweight internal wrapper that inherits from `BaseConnector`. This ensures compatibility with the platform's standard routing and manifest generation while centralizing the actual execution logic. +- **`FhirCernerConnector`**: A single `BaseConnector` subclass that encapsulates all shared logic, authentication flows, and the per-action implementation methods. Each action is a method decorated with **`@sdk_action`** or **`@nw_action`** (e.g. `read_patient`, `search_patients`, `search_encounter`). The runtime derives routing and manifest entries from that decorator metadata (`sdk_action_metas()` → `build_manifest`) — there is no separate per-action wrapper class. --- @@ -51,7 +49,7 @@ The connector implements the **SMART Backend Services** specification (specifica ## 3. Supported Operations -The connector exposes four primary actions, each with standardized request/response models. +The connector exposes five primary actions, each with standardized request/response models. ### `read_patient` @@ -64,6 +62,17 @@ Retrieves patient details either by a direct resource ID or through search param --- +### `search_patients` + +Fetches or searches for multiple FHIR Patient resources — either a list of `resource_ids` fetched concurrently, or demographic search parameters. + +| Field | Detail | +|---|---| +| **Input** | `resource_ids` OR demographic params (`given_name`, `family_name`, `name`, `birthdate`) / `search_params` | +| **Output** | List of Patient resources and the total count | + +--- + ### `search_encounter` Searches for medical encounters for a specific patient. This is often a prerequisite for creating clinical notes, as Cerner requires a valid encounter reference. @@ -118,7 +127,7 @@ Cerner's FHIR implementation (especially in the sandbox) has several unique requ | `src/node_wire_fhir_cerner/logic.py` | Core logic, authentication, and action dispatch | | `src/node_wire_fhir_cerner/schema.py` | Pydantic input/output models and field-level documentation | | `src/node_wire_fhir_cerner/registration.py` | Error mapping and exception handling specifically for Cerner API errors | -| `postman_fhir_cerner_collection.json` | Pre-configured requests to test endpoints end-to-end (at repo root) | +| `tests/playground/cerner/` | Runnable end-to-end verification scripts | --- diff --git a/src/node_wire_fhir_epic/README.md b/src/node_wire_fhir_epic/README.md index f96e65e..7361edf 100644 --- a/src/node_wire_fhir_epic/README.md +++ b/src/node_wire_fhir_epic/README.md @@ -9,9 +9,8 @@ SPDX-License-Identifier: Apache-2.0 > **Platform:** Node Wire > **Standard:** FHIR R4 > **Auth Method:** SMART Backend Services — RS384 JWT / OAuth2 -> **Actions:** `read_patient` · `search_encounter` · `create_document_reference` · `search_document_reference` +> **Actions:** `read_patient` · `search_patients` · `search_encounter` · `create_document_reference` · `search_document_reference` > **Source:** `src/node_wire_fhir_epic/` -> **Test Collection:** `postman_fhir_epic_collection.json` --- @@ -21,10 +20,9 @@ The FHIR Epic connector is designed to interface with Epic EHR systems using the ### Logic Consolidation -Initially, each action (e.g., `read_patient`, `search_encounter`) was implemented in its own class. This led to code duplication and a cluttered workspace. We refactored this into a single **"Fat Connector"** architecture: +All actions live in a single **"Fat Connector"** class rather than one class per action: -- **`FhirEpicConnector`**: A single class that encapsulates all shared logic, authentication flows (JWT/OAuth2), and the specific implementation methods for each action. -- **`_FhirAction`**: A lightweight internal wrapper that inherits from `BaseConnector`. This allows the connector to remain compatible with the platform's standard routing and manifest generation while centralizing the actual execution logic. +- **`FhirEpicConnector`**: A single `BaseConnector` subclass that encapsulates all shared logic, authentication flows (JWT/OAuth2), and the per-action implementation methods. Each action is a method decorated with **`@sdk_action`** or **`@nw_action`** (e.g. `read_patient`, `search_patients`, `search_encounter`). The base class derives routing and manifest entries from that decorator metadata — there is no separate per-action wrapper class. --- @@ -36,8 +34,8 @@ To support this consolidated architecture while maintaining a clean codebase, se The `ConnectorFactory` was updated to support connectors that manage many actions internally. -- **Design Decision**: Instead of the factory returning a list of 4 different connector instances for `fhir_epic`, it now returns **one instance** of the `FhirEpicConnector` class. -- **Action Discovery**: The factory uses `list_actions()` and `get_action(name)` helpers on the connector instance to discover and dispatch specific operations. This keeps the `_connectors` dictionary clean (one entry per `connector_id`). +- **Design Decision**: Instead of the factory returning a list of different connector instances for `fhir_epic`, it returns **one instance** of the `FhirEpicConnector` class. This keeps the `_connectors` dictionary clean (one entry per `connector_id`). +- **Action Discovery**: The runtime discovers actions from the `@sdk_action`/`@nw_action` metadata on the connector (`sdk_action_metas()` → `build_manifest` in `node_wire_runtime/manifest.py`), emitting one manifest entry and one REST route per action. ### `app.py` — 422 Unprocessable Entity Fix @@ -114,7 +112,7 @@ By using this standardized model, client consumers can handle errors predictably ## 4. Manual Verification -A Postman collection is provided at the root: `postman_fhir_epic_collection.json`. +Exercise the connector with the REST `curl` examples in [`docs/connectors.md`](../../docs/connectors.md) / the Swagger UI at `http://localhost:8000/docs`, or the runnable scripts under `tests/playground/epic_fhir/`. **Recommended Test Flow:** diff --git a/src/node_wire_google_drive/README.md b/src/node_wire_google_drive/README.md index 2a285a4..bb3ab9c 100644 --- a/src/node_wire_google_drive/README.md +++ b/src/node_wire_google_drive/README.md @@ -10,7 +10,7 @@ SPDX-License-Identifier: Apache-2.0 > **Connector ID:** `google_drive` > **REST:** One route per operation, e.g. `POST /connectors/google_drive/files.list` (the `action` field is still set on the body for `BaseConnector` dispatch). > **Discriminator:** `action` field (discriminated-union payload) -> **Source:** `connectors/google_drive/` +> **Source:** `src/node_wire_google_drive/` --- @@ -24,7 +24,7 @@ The runtime validates requests against the discriminated union in `schema.py`, t |-------|------| | [`action_spec.py`](action_spec.py) | `GOOGLE_DRIVE_ACTION_SPECS`: per-action `SdkActionSpec` (resource path, method, field/body mapping, constants, optional `build_kwargs` / `post_process`). | | [`logic.py`](logic.py) | Client build, `_translate_and_raise_http_error`, `_execute_action_spec`, thin `@nw_action` methods. | -| [`runtime/sdk_action_spec.py`](../../runtime/sdk_action_spec.py) | Reusable primitives: `SdkActionSpec`, `default_build_kwargs`, `execute_spec_in_thread`. | +| [`node_wire_runtime/sdk_action_spec.py`](../node_wire_runtime/sdk_action_spec.py) | Reusable primitives: `SdkActionSpec`, `default_build_kwargs`, `execute_spec_in_thread`. | **Adding a new operation:** Add a Pydantic variant in `schema.py` (with an `action` discriminator literal), extend the `GoogleDriveOperationInput` union, and add an entry to `GOOGLE_DRIVE_ACTION_SPECS` in `action_spec.py` (or a `build_kwargs` hook for non-generic cases such as multipart upload). `BaseConnector.__init_subclass__` auto-generates the handler — do **not** also add an `@nw_action` method for the same action name, as that will raise a `TypeError` at class-definition time. From 45772210fa2ac4dd799528a427b50751bc48d9f2 Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:01:00 -0700 Subject: [PATCH 04/20] Prepare v0.1.0 open-source release readiness Commit gRPC protobuf stubs (with Ruff/Mypy excludes), decouple REST from playground, align nine-package publish and security CI, resolve pip-audit CVEs, add REUSE/SPDX compliance, and polish governance and docs for public release. --- .github/ISSUE_TEMPLATE/config.yml | 7 +- .github/workflows/lint.yml | 27 + .github/workflows/publish.yml | 2 + .github/workflows/quality-gates.yml | 220 ++--- .github/workflows/security-pr.yml | 140 ++-- .pre-commit-config.yaml | 65 +- CHANGELOG.md | 31 + CODE_OF_CONDUCT.md | 1 - DEPENDENCIES.md | 4 + Dockerfile | 2 + LICENSES/CC0-1.0.txt | 121 +++ README.md | 5 + REUSE.toml | 20 + SECURITY.md | 1 - docker/salesforce/Dockerfile | 6 +- docker/slack/Dockerfile | 4 + docker/stripe/Dockerfile | 4 + docs/architecture.md | 4 + docs/compliance/hipaa-considerations.md | 28 +- docs/configuration.md | 6 + docs/installation.md | 12 +- docs/mcp-client-oauth.md | 4 + docs/mcp-servers.md | 2 +- docs/mcp.md | 4 + docs/packaging.md | 4 +- docs/privacy.md | 18 +- docs/quality-security-gates.md | 6 + docs/salesforce_connector.md | 4 + docs/slack_connector.md | 4 + docs/toolhive_agent_scenario.md | 10 +- docs/troubleshooting.md | 4 + grafana/README.md | 10 +- grafana/docker-compose.yml | 24 +- packages/connectors/fhir_cerner/__init__.py | 4 + .../connectors/fhir_cerner/pyproject.toml | 3 +- packages/connectors/fhir_epic/__init__.py | 4 + packages/connectors/fhir_epic/pyproject.toml | 3 +- packages/connectors/google_drive/__init__.py | 4 + .../connectors/google_drive/pyproject.toml | 3 +- packages/connectors/http_generic/__init__.py | 4 + .../connectors/http_generic/pyproject.toml | 3 +- packages/connectors/salesforce/pyproject.toml | 9 +- packages/connectors/salesforce/setup.py | 4 + packages/connectors/slack/pyproject.toml | 9 +- packages/connectors/slack/setup.py | 4 + packages/connectors/smtp/__init__.py | 4 + packages/connectors/smtp/pyproject.toml | 3 +- packages/connectors/stripe/__init__.py | 4 + packages/connectors/stripe/pyproject.toml | 3 +- packages/runtime/README.md | 11 + packages/runtime/pyproject.toml | 3 +- playground/ext_patient_viewer/__init__.py | 5 + playground/ext_patient_viewer/schema.py | 4 + pyproject.toml | 16 +- sample.env | 4 +- scripts/bandit_report_summary.py | 4 + scripts/build-mcp-images.sh | 1 + scripts/demo_mcp_oauth_mock.py | 248 +++--- scripts/generate-grpc-stubs.sh | 35 + scripts/run-compliance-checks.sh | 23 +- sonar-project.properties | 3 + src/agents/salesforce_mcp.py | 4 + src/agents/slack_mcp.py | 4 + src/agents/stripe_mcp.py | 4 + src/bindings/__init__.py | 30 +- src/bindings/grpc_server/__init__.py | 6 + src/bindings/grpc_server/auth.py | 4 + src/bindings/grpc_server/connector.proto | 48 +- src/bindings/grpc_server/connector_pb2.py | 45 ++ .../grpc_server/connector_pb2_grpc.py | 102 +++ src/bindings/grpc_server/server.py | 13 +- src/bindings/mcp_server/auth.py | 4 + src/bindings/rest_api/app.py | 62 +- src/bindings/rest_api/rate_limit.py | 202 ++--- src/bindings_entrypoint.py | 3 +- src/node_wire_google_drive/__init__.py | 12 +- src/node_wire_google_drive/exceptions.py | 70 +- src/node_wire_google_drive/schema.py | 288 +++---- src/node_wire_http_generic/__init__.py | 12 +- src/node_wire_http_generic/schema.py | 206 ++--- src/node_wire_runtime/auth/__init__.py | 4 + src/node_wire_runtime/auth/base.py | 4 + src/node_wire_runtime/auth/no_auth.py | 4 + src/node_wire_runtime/auth/oauth2.py | 4 + src/node_wire_runtime/auth/service_account.py | 4 + src/node_wire_runtime/auth/static_token.py | 4 + src/node_wire_runtime/caller_identity.py | 4 + src/node_wire_runtime/errors.py | 94 +-- src/node_wire_runtime/log_sanitization.py | 4 + src/node_wire_runtime/mcp_client/__init__.py | 270 +++---- .../mcp_client/challenges.py | 112 +-- src/node_wire_runtime/mcp_client/client.py | 466 +++++------ src/node_wire_runtime/mcp_client/config.py | 326 ++++---- src/node_wire_runtime/mcp_client/dcr.py | 362 ++++----- src/node_wire_runtime/mcp_client/discovery.py | 578 +++++++------- .../mcp_client/env_config.py | 196 ++--- .../mcp_client/exceptions.py | 78 +- .../mcp_client/oauth_flow.py | 728 ++++++++--------- .../mcp_client/redirect_listener.py | 382 ++++----- src/node_wire_runtime/mcp_client/storage.py | 166 ++-- .../mcp_client/token_manager.py | 616 +++++++------- .../mcp_client/token_storage.py | 574 +++++++------- src/node_wire_runtime/models.py | 62 +- .../policies/mcp_scope_policy.py | 374 ++++----- src/node_wire_runtime/policy.py | 82 +- src/node_wire_runtime/rate_limit.py | 4 + src/node_wire_runtime/streaming.py | 4 + src/node_wire_salesforce/__init__.py | 5 + src/node_wire_salesforce/logic.py | 4 + src/node_wire_salesforce/registration.py | 4 + src/node_wire_salesforce/schema.py | 4 + src/node_wire_slack/README.md | 4 + src/node_wire_slack/__init__.py | 5 + src/node_wire_slack/exceptions.py | 4 + src/node_wire_slack/logic.py | 4 + src/node_wire_slack/registration.py | 4 + src/node_wire_slack/schema.py | 4 + src/node_wire_smtp/__init__.py | 12 +- src/node_wire_smtp/schema.py | 198 ++--- src/node_wire_stripe/README.md | 4 + src/node_wire_stripe/__init__.py | 12 +- src/node_wire_stripe/schema.py | 254 +++--- tests/fixtures/connectors_for_tests.yaml | 5 + tests/jwt_test_helpers.py | 4 + tests/playground/salesforce/__init__.py | 4 + tests/test_api_key_compare.py | 4 + tests/test_auth_providers.py | 4 + tests/test_bandit_report_summary.py | 4 + tests/test_call_action_policy.py | 4 + tests/test_entrypoints.py | 2 +- tests/test_fhir_logging.py | 4 + tests/test_grpc_async_runner.py | 4 + tests/test_grpc_scope_policy.py | 4 + tests/test_jwt_validation.py | 4 + tests/test_log_sanitization.py | 4 + tests/test_mcp_auth.py | 4 + tests/test_mcp_oauth_client.py | 412 +++++----- tests/test_mcp_oauth_config.py | 200 ++--- tests/test_mcp_oauth_conformance.py | 190 ++--- tests/test_mcp_oauth_dcr.py | 242 +++--- tests/test_mcp_oauth_discovery.py | 200 ++--- tests/test_mcp_oauth_flow.py | 282 +++---- tests/test_mcp_oauth_token_lifecycle.py | 370 ++++----- tests/test_mcp_transport.py | 4 + tests/test_network_bindings.py | 4 + tests/test_payload_redaction.py | 4 + tests/test_rest_app_import_env.py | 4 + tests/test_rest_body_limit.py | 4 + tests/test_rest_identity_key.py | 4 + tests/test_rest_rate_limit_enforcement.py | 354 +++++---- tests/test_runtime_resilience.py | 462 +++++------ tests/test_salesforce.py | 4 + tests/test_scope_policy_transport.py | 4 + tests/test_slack_connector.py | 4 + tests/test_stripe.py | 4 + uv.lock | 749 +++++++++++------- 156 files changed, 6502 insertions(+), 5430 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 LICENSES/CC0-1.0.txt create mode 100644 REUSE.toml create mode 100644 packages/runtime/README.md create mode 100755 scripts/generate-grpc-stubs.sh create mode 100644 src/bindings/grpc_server/__init__.py create mode 100644 src/bindings/grpc_server/connector_pb2.py create mode 100644 src/bindings/grpc_server/connector_pb2_grpc.py diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index fe19ebe..a23a22d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,6 +1,7 @@ -# SPDX-FileCopyrightText: 2026 AOT Technologies -# -# SPDX-License-Identifier: Apache-2.0 +## +## SPDX-FileCopyrightText: 2026 AOT Technologies +## SPDX-License-Identifier: Apache-2.0 +## blank_issues_enabled: false contact_links: - name: Security vulnerability diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index bb125bc..a9008ef 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,6 +9,8 @@ name: Lint and Type Check on: pull_request: branches: [ "main" ] + push: + branches: [main, master] jobs: lockfile-check: @@ -57,6 +59,31 @@ jobs: - name: Run Ruff (Formatting Check) run: uv run ruff format --check . + reuse: + name: REUSE compliance + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock + + - name: Install dependencies + run: uv sync --frozen --all-extras --dev + + - name: Run REUSE lint + run: uv run reuse lint + mypy: name: Mypy Type Check runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 74a2252..05c9a90 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -70,6 +70,8 @@ jobs: "packages/connectors/google_drive", "packages/connectors/fhir_cerner", "packages/connectors/fhir_epic", + "packages/connectors/salesforce", + "packages/connectors/slack", } if norm not in allowed: print(f"ERROR: package_path {norm!r} is not allowlisted.", file=sys.stderr) diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 7859593..8475c03 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -1,110 +1,110 @@ - -## -## SPDX-FileCopyrightText: 2026 AOT Technologies -## SPDX-License-Identifier: Apache-2.0 -## - -name: Quality gates - -on: - pull_request: - push: - branches: [main, master] - -# This workflow enforces Bandit, tests/coverage, and SonarQube. - -jobs: - bandit: - name: Bandit security scan - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Set up Python - uses: actions/setup-python@v5.3.0 - with: - python-version: "3.11" - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - cache-dependency-glob: | - pyproject.toml - uv.lock - - name: Install dependencies - run: uv sync --frozen --all-extras --dev - # Bandit exits non-zero when *any* finding exists (incl. low/medium). The - # enforce step below gates on high only; use --exit-zero here so this step - # always produces the JSON artifact and the job can print a summary. - - name: Generate Bandit JSON report - run: uv run bandit -c pyproject.toml -r src -f json -o bandit-report.json --exit-zero - - name: Bandit findings summary (log) - run: uv run python scripts/bandit_report_summary.py bandit-report.json - - name: Upload Bandit report artifact - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 - with: - name: bandit-report - path: bandit-report.json - if-no-files-found: error - - name: Enforce high-severity Bandit gate - run: uv run bandit -c pyproject.toml -r src --severity-level high - - test: - name: Tests and coverage - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Set up Python - uses: actions/setup-python@v5.3.0 - with: - python-version: "3.11" - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - cache-dependency-glob: | - pyproject.toml - uv.lock - - name: Install dependencies - run: uv sync --frozen --all-extras --dev - - name: Run tests (coverage.xml generated via pyproject addopts) - run: uv run pytest tests/ -v - - name: Generate SBOM - run: uv run cyclonedx-py environment -o sbom.json - - name: Upload coverage artifact - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 - with: - name: coverage-xml - path: coverage.xml - if-no-files-found: error - - name: Upload SBOM artifact - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 - with: - name: sbom - path: sbom.json - if-no-files-found: error - - sonar: - name: SonarQube analysis - runs-on: ubuntu-latest - needs: [bandit, test] - # Sonar scan requires repository secrets; skip gracefully when unavailable - # (e.g. PRs from forks where secrets are not exposed). - if: ${{ secrets.SONAR_TOKEN != '' && secrets.SONAR_HOST_URL != '' }} - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 - - name: Download coverage artifact - uses: actions/download-artifact@v4 - with: - name: coverage-xml - path: . - - name: SonarQube scan (wait for quality gate) - uses: SonarSource/sonarqube-scan-action@v5.3.1 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} - with: - args: > - -Dsonar.qualitygate.wait=true - -Dsonar.qualitygate.timeout=300 + +## +## SPDX-FileCopyrightText: 2026 AOT Technologies +## SPDX-License-Identifier: Apache-2.0 +## + +name: Quality gates + +on: + pull_request: + push: + branches: [main, master] + +# This workflow enforces Bandit, tests/coverage, and SonarQube. + +jobs: + bandit: + name: Bandit security scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Set up Python + uses: actions/setup-python@v5.3.0 + with: + python-version: "3.11" + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock + - name: Install dependencies + run: uv sync --frozen --all-extras --dev + # Bandit exits non-zero when *any* finding exists (incl. low/medium). The + # enforce step below gates on high only; use --exit-zero here so this step + # always produces the JSON artifact and the job can print a summary. + - name: Generate Bandit JSON report + run: uv run bandit -c pyproject.toml -r src -f json -o bandit-report.json --exit-zero + - name: Bandit findings summary (log) + run: uv run python scripts/bandit_report_summary.py bandit-report.json + - name: Upload Bandit report artifact + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: bandit-report + path: bandit-report.json + if-no-files-found: error + - name: Enforce high-severity Bandit gate + run: uv run bandit -c pyproject.toml -r src --severity-level high + + test: + name: Tests and coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Set up Python + uses: actions/setup-python@v5.3.0 + with: + python-version: "3.11" + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock + - name: Install dependencies + run: uv sync --frozen --all-extras --dev + - name: Run tests (coverage.xml generated via pyproject addopts) + run: uv run pytest tests/ -v + - name: Generate SBOM + run: uv run cyclonedx-py environment -o sbom.json + - name: Upload coverage artifact + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: coverage-xml + path: coverage.xml + if-no-files-found: error + - name: Upload SBOM artifact + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: sbom + path: sbom.json + if-no-files-found: error + + sonar: + name: SonarQube analysis + runs-on: ubuntu-latest + needs: [bandit, test] + # Sonar scan requires repository secrets; skip gracefully when unavailable + # (e.g. PRs from forks where secrets are not exposed). + if: ${{ secrets.SONAR_TOKEN != '' && secrets.SONAR_HOST_URL != '' }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + - name: Download coverage artifact + uses: actions/download-artifact@v4 + with: + name: coverage-xml + path: . + - name: SonarQube scan (wait for quality gate) + uses: SonarSource/sonarqube-scan-action@v5.3.1 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + with: + args: > + -Dsonar.qualitygate.wait=true + -Dsonar.qualitygate.timeout=300 diff --git a/.github/workflows/security-pr.yml b/.github/workflows/security-pr.yml index 264ef7f..a3ff682 100644 --- a/.github/workflows/security-pr.yml +++ b/.github/workflows/security-pr.yml @@ -1,69 +1,71 @@ -## -## SPDX-FileCopyrightText: 2026 AOT Technologies -## SPDX-License-Identifier: Apache-2.0 -### Continuous security checks for publishable Python packages on pull requests. - - -name: Python package security PR checks - -on: - pull_request: - paths: - - ".github/workflows/security-pr.yml" - - ".github/workflows/publish.yml" - - "pyproject.toml" - - "uv.lock" - - "packages/**" - - "src/**" - push: - branches: [main, master] - paths: - - ".github/workflows/security-pr.yml" - - ".github/workflows/publish.yml" - - "pyproject.toml" - - "uv.lock" - - "packages/**" - - "src/**" - schedule: - - cron: "17 3 * * *" - -env: - PIP_AUDIT_VERSION: "2.7.3" - -jobs: - vulnerability-scan: - name: Vulnerability scan (${{ matrix.package_path }}) - runs-on: ubuntu-latest - permissions: - contents: read - strategy: - fail-fast: false - matrix: - package_path: - - packages/runtime - - packages/connectors/http_generic - - packages/connectors/stripe - - packages/connectors/smtp - - packages/connectors/google_drive - - packages/connectors/fhir_cerner - - packages/connectors/fhir_epic - - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Set up Python - uses: actions/setup-python@v5.3.0 - with: - python-version: "3.11" - - # Connector packages declare node-wire-runtime>=0.1.0 as a PyPI-style dep; install - # packages/runtime from the repo so pip resolves it without requiring a published wheel. - - name: Install package and audit tool - run: | - python -m pip install --upgrade pip - python -m pip install --upgrade "setuptools>=78.1.1" - python -m pip install "packages/runtime" "${{ matrix.package_path }}" - python -m pip install "pip-audit==${{ env.PIP_AUDIT_VERSION }}" - - - name: Vulnerability scan (blocks any vulnerability) - run: pip-audit +## +## SPDX-FileCopyrightText: 2026 AOT Technologies +## SPDX-License-Identifier: Apache-2.0 +## + + +name: Python package security PR checks + +on: + pull_request: + paths: + - ".github/workflows/security-pr.yml" + - ".github/workflows/publish.yml" + - "pyproject.toml" + - "uv.lock" + - "packages/**" + - "src/**" + push: + branches: [main, master] + paths: + - ".github/workflows/security-pr.yml" + - ".github/workflows/publish.yml" + - "pyproject.toml" + - "uv.lock" + - "packages/**" + - "src/**" + schedule: + - cron: "17 3 * * *" + +env: + PIP_AUDIT_VERSION: "2.7.3" + +jobs: + vulnerability-scan: + name: Vulnerability scan (${{ matrix.package_path }}) + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + package_path: + - packages/runtime + - packages/connectors/http_generic + - packages/connectors/stripe + - packages/connectors/smtp + - packages/connectors/google_drive + - packages/connectors/fhir_cerner + - packages/connectors/fhir_epic + - packages/connectors/salesforce + - packages/connectors/slack + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up Python + uses: actions/setup-python@v5.3.0 + with: + python-version: "3.11" + + # Connector packages declare node-wire-runtime>=0.1.0 as a PyPI-style dep; install + # packages/runtime from the repo so pip resolves it without requiring a published wheel. + - name: Install package and audit tool + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade "setuptools>=78.1.1" + python -m pip install "packages/runtime" "${{ matrix.package_path }}" + python -m pip install "pip-audit==${{ env.PIP_AUDIT_VERSION }}" + + - name: Vulnerability scan (blocks any vulnerability) + run: pip-audit diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e813c81..75e6108 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,28 +1,37 @@ -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.5 - hooks: - - id: ruff - args: [ --fix ] - - id: ruff-format - - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.9.0 - hooks: - - id: mypy - pass_filenames: false - additional_dependencies: ["pydantic", "fastapi"] - - - repo: https://github.com/PyCQA/bandit - rev: 1.8.6 - hooks: - - id: bandit - args: ["-c", "pyproject.toml", "-r", "src"] - pass_filenames: false +## +## SPDX-FileCopyrightText: 2026 AOT Technologies +## SPDX-License-Identifier: Apache-2.0 +## +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.15 + hooks: + - id: ruff + args: [ --fix ] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.9.0 + hooks: + - id: mypy + pass_filenames: false + additional_dependencies: ["pydantic", "fastapi"] + + - repo: https://github.com/fsfe/reuse-tool + rev: v5.0.2 + hooks: + - id: reuse + + - repo: https://github.com/PyCQA/bandit + rev: 1.8.6 + hooks: + - id: bandit + args: ["-c", "pyproject.toml", "-r", "src"] + pass_filenames: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ab1a2cd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-06-26 + +### Added + +- Initial public release of the Node Wire platform: runtime, connectors, and bindings. +- Nine publishable Python packages: runtime plus eight connectors (HTTP generic, Google Drive, SMTP, Stripe, Epic FHIR, Cerner FHIR, Salesforce, Slack). +- REST, gRPC, and MCP entrypoints with authentication, scope policy, and observability hooks. +- Per-connector MCP Docker images and unified MCP server (`agents.mcp_entrypoint`). +- ToolHive agent scenario documentation and sample agent workflow. +- CI quality gates: Ruff, Mypy, pytest, Bandit, pip-audit, and REUSE compliance. +- Governance docs: contributing guide, security policy, code of conduct, privacy notes, and HIPAA considerations. + +### Fixed + +- gRPC protobuf stubs committed and importable for production startup. +- REST API no longer requires the optional `playground` package at import time. +- Dependency lockfile upgraded to resolve known CVEs in transitive packages. +- Packaging, publish workflow, and security scanning aligned on the nine-package surface. + +[0.1.0]: https://github.com/AOT-Technologies/node-wire/releases/tag/v0.1.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 4aa2600..fc0ca17 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -67,7 +67,6 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at **opensource@aot-technologies.com**. - All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index 5534e83..a1cd803 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + # Node Wire Open Source Dependencies This file is automatically generated and contains an inventory of all third-party dependencies used in the Node Wire project. diff --git a/Dockerfile b/Dockerfile index f94cafa..4720ede 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,7 @@ COPY packages/connectors/slack/dist/*.whl /wheels/ COPY packages/connectors/google_drive/dist/*.whl /wheels/ COPY packages/connectors/fhir_cerner/dist/*.whl /wheels/ COPY packages/connectors/fhir_epic/dist/*.whl /wheels/ +COPY packages/connectors/salesforce/dist/*.whl /wheels/ ENV PYTHONPATH=/app/src @@ -50,6 +51,7 @@ RUN pip install --no-cache-dir --find-links=/wheels \ node-wire-google-drive \ node-wire-fhir-cerner \ node-wire-fhir-epic \ + node-wire-salesforce \ "mcp>=1.6.0" \ && rm -rf /wheels diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/LICENSES/CC0-1.0.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/README.md b/README.md index 285448a..c558d93 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,11 @@ For more detailed information, please refer to the following guides: - **[MCP Servers & Docker](docs/mcp-servers.md)** — Deploying individual connectors as MCP servers. - **[Packaging & Publishing](docs/packaging.md)** — Wheel builds and CI flow. - **[Code Quality & Compliance](docs/code-quality-compliance.md)** — Ruff, Mypy, pre-commit, REUSE, and dependency compliance. +- **[Privacy](docs/privacy.md)** — Data handling and logging guidance. +- **[HIPAA Considerations](docs/compliance/hipaa-considerations.md)** — Deploying Node Wire in regulated healthcare environments. +- **[ToolHive Agent Scenario](docs/toolhive_agent_scenario.md)** — End-to-end FHIR → Google Drive → email workflow. +- **[Changelog](CHANGELOG.md)** — Release history. + ## Developer docs - Individual connector MCP servers (ToolHive): [docs/mcp-servers.md](docs/mcp-servers.md) diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000..56a67bb --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + +version = 1 +SPDX-PackageName = "node-wire" +SPDX-PackageSupplier = "AOT Technologies " +SPDX-PackageDownloadLocation = "https://github.com/AOT-Technologies/node-wire" + +[[annotations]] +path = "uv.lock" +precedence = "override" +SPDX-FileCopyrightText = "NONE" +SPDX-License-Identifier = "CC0-1.0" + +[[annotations]] +path = "tests/fixtures/**" +precedence = "override" +SPDX-FileCopyrightText = "2026 AOT Technologies" +SPDX-License-Identifier = "Apache-2.0" diff --git a/SECURITY.md b/SECURITY.md index 9086ebf..d816a5a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,7 +30,6 @@ Report suspected vulnerabilities privately through either of the following: [private vulnerability reporting](https://github.com/AOT-Technologies/node-wire/security/advisories/new) form for this repository (preferred). - **Email** — send details to **security@aot-technologies.com**. - When reporting, please include as much of the following as you can: diff --git a/docker/salesforce/Dockerfile b/docker/salesforce/Dockerfile index e255d6a..a5d10dd 100644 --- a/docker/salesforce/Dockerfile +++ b/docker/salesforce/Dockerfile @@ -1,3 +1,7 @@ +## +## SPDX-FileCopyrightText: 2026 AOT Technologies +## SPDX-License-Identifier: Apache-2.0 +## FROM python:3.12-slim@sha256:3d5ed973e45820f5ba5e46bd065bd88b3a504ff0724d85980dcd05eab361fcf4 LABEL org.opencontainers.image.title="nw-salesforce" \ @@ -21,7 +25,7 @@ ENV PYTHONPATH=/app/src \ RUN pip install --no-cache-dir --find-links=/wheels \ node-wire-runtime node-wire-salesforce "mcp>=1.6.0" httpx \ - || pip install --no-cache-dir "mcp>=1.6.0" httpx # Fallback if wheels missing + && rm -rf /wheels RUN groupadd --system --gid 1000 app \ && useradd --system --uid 1000 --gid app --home /app app \ diff --git a/docker/slack/Dockerfile b/docker/slack/Dockerfile index 8b4b3c4..4541f1f 100644 --- a/docker/slack/Dockerfile +++ b/docker/slack/Dockerfile @@ -1,3 +1,7 @@ +## +## SPDX-FileCopyrightText: 2026 AOT Technologies +## SPDX-License-Identifier: Apache-2.0 +## FROM python:3.12-slim@sha256:3d5ed973e45820f5ba5e46bd065bd88b3a504ff0724d85980dcd05eab361fcf4 LABEL org.opencontainers.image.title="nw-slack" \ diff --git a/docker/stripe/Dockerfile b/docker/stripe/Dockerfile index 552c46a..85fdbc7 100644 --- a/docker/stripe/Dockerfile +++ b/docker/stripe/Dockerfile @@ -1,3 +1,7 @@ +## +## SPDX-FileCopyrightText: 2026 AOT Technologies +## SPDX-License-Identifier: Apache-2.0 +## FROM python:3.12-slim@sha256:3d5ed973e45820f5ba5e46bd065bd88b3a504ff0724d85980dcd05eab361fcf4 LABEL org.opencontainers.image.title="nw-stripe" \ diff --git a/docs/architecture.md b/docs/architecture.md index 781e2d2..b67a6a7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + # Node Wire Architecture The Node Wire platform is designed as a three-layer Python platform that runs connector adapters over REST, gRPC, or MCP. Each connector talks to an external system (e.g., Google Drive, SMTP, Stripe); the runtime provides a consistent execution contract, error handling, and resilience. diff --git a/docs/compliance/hipaa-considerations.md b/docs/compliance/hipaa-considerations.md index ce4be2c..2443070 100644 --- a/docs/compliance/hipaa-considerations.md +++ b/docs/compliance/hipaa-considerations.md @@ -1,35 +1,39 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + # HIPAA Compliance Considerations -Node-Wire provides connectors for healthcare systems, including Epic and Cerner FHIR APIs. While Node-Wire is designed with security in mind, deploying Node-Wire in a healthcare environment to process Protected Health Information (PHI) requires careful consideration to maintain compliance with the Health Insurance Portability and Accountability Act (HIPAA). +Node Wire provides connectors for healthcare systems, including Epic and Cerner FHIR APIs. While Node Wire is designed with security in mind, deploying Node Wire in a healthcare environment to process Protected Health Information (PHI) requires careful consideration to maintain compliance with the Health Insurance Portability and Accountability Act (HIPAA). > [!WARNING] -> Node-Wire is a software framework, not a managed service. You are solely responsible for ensuring that your deployment, configuration, and infrastructure meet all applicable HIPAA requirements. +> Node Wire is a software framework, not a managed service. You are solely responsible for ensuring that your deployment, configuration, and infrastructure meet all applicable HIPAA requirements. ## 1. Business Associate Agreements (BAAs) -Since Node-Wire acts as a middleware layer routing data between various systems (e.g., your EHR, your LLM provider, and external services), you must have a Business Associate Agreement (BAA) in place with **every** third-party service provider that touches PHI. +Since Node Wire acts as a middleware layer routing data between various systems (e.g., your EHR, your LLM provider, and external services), you must have a Business Associate Agreement (BAA) in place with **every** third-party service provider that touches PHI. -- **LLM Providers:** If you are using OpenAI, Anthropic, Google, or Groq to process PHI via Node-Wire agents, you must have a BAA signed with that provider and ensure you are using their HIPAA-eligible endpoints/models. -- **Hosting Infrastructure:** If you deploy Node-Wire on AWS, Azure, Google Cloud, or another cloud provider, you must have a BAA with the hosting provider. +- **LLM Providers:** If you are using OpenAI, Anthropic, Google, or Groq to process PHI via Node Wire agents, you must have a BAA signed with that provider and ensure you are using their HIPAA-eligible endpoints/models. +- **Hosting Infrastructure:** If you deploy Node Wire on AWS, Azure, Google Cloud, or another cloud provider, you must have a BAA with the hosting provider. - **External Connectors:** If you use connectors like SMTP or Google Drive to send or store PHI, those services must also be covered under a BAA. ## 2. Data in Transit (Encryption) All network traffic involving PHI must be encrypted. -- **EHR Communication:** Node-Wire's FHIR connectors use HTTPS/TLS to communicate with Epic and Cerner APIs. -- **Client Communication:** When deploying the Node-Wire REST API or MCP Server, you must place it behind a reverse proxy (e.g., Nginx, Traefik) or API Gateway configured with strict TLS 1.2+ encryption. +- **EHR Communication:** Node Wire's FHIR connectors use HTTPS/TLS to communicate with Epic and Cerner APIs. +- **Client Communication:** When deploying the Node Wire REST API or MCP Server, you must place it behind a reverse proxy (e.g., Nginx, Traefik) or API Gateway configured with strict TLS 1.2+ encryption. ## 3. Data at Rest (Persistence) -Node-Wire itself does not include a database and does not persistently store PHI. It processes data in memory during execution. However, consider the following: -- **Logs:** Ensure that your logging infrastructure does not capture PHI. Node-Wire's default `INFO` logging levels do not log payloads, but running in `DEBUG` or `TRACE` mode may expose PHI to logs. You must configure your logging systems to redact PHI or ensure the logging environment is HIPAA-compliant. -- **Caching:** If you implement caching layers on top of Node-Wire, ensure the cache is encrypted at rest. +Node Wire itself does not include a database and does not persistently store PHI. It processes data in memory during execution. However, consider the following: +- **Logs:** Ensure that your logging infrastructure does not capture PHI. Node Wire's default `INFO` logging levels do not log payloads, but running in `DEBUG` or `TRACE` mode may expose PHI to logs. You must configure your logging systems to redact PHI or ensure the logging environment is HIPAA-compliant. +- **Caching:** If you implement caching layers on top of Node Wire, ensure the cache is encrypted at rest. ## 4. Authentication and Authorization -- **API Keys & JWTs:** Node-Wire's REST API supports API keys and JWTs. Ensure these secrets are strong, rotated regularly, and never hardcoded in source control. +- **API Keys & JWTs:** Node Wire's REST API supports API keys and JWTs. Ensure these secrets are strong, rotated regularly, and never hardcoded in source control. - **OAuth 2.0 / SMART on FHIR:** The FHIR connectors rely on the underlying authentication provided by the EHR. Ensure that the service accounts or client applications registered in Epic/Cerner are provisioned with the principle of least privilege, granting access only to the specific FHIR resources required by the agents. ## 5. Safe Secret Management -Do not store credentials (e.g., `client_secret`, API keys) in plain text environment files in production. Node-Wire supports Pluggable Secret Providers (e.g., HashiCorp Vault, Azure Key Vault, AWS Secrets Manager). You should use a secure secret management solution to inject credentials at runtime. +Do not store credentials (e.g., `client_secret`, API keys) in plain text environment files in production. Node Wire supports Pluggable Secret Providers (e.g., HashiCorp Vault, Azure Key Vault, AWS Secrets Manager). You should use a secure secret management solution to inject credentials at runtime. diff --git a/docs/configuration.md b/docs/configuration.md index c728866..752d454 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + # Configuration Guide Node Wire is configured primarily through environment variables and a YAML configuration file. @@ -39,6 +43,8 @@ copy sample.env .env |----------|-------------|---------| | `MODE` | Execution mode (`API`, `GRPC`, `MCP`) | `API` | | `PORT` | Port for the REST API | `8000` | +| `NW_REST_HOST` | REST API bind address | `127.0.0.1` | +| `NW_REST_PLAYGROUND_ENABLED` | Mount the interactive playground at `/playground/` when `true`; when unset, enabled only if a `playground/` directory exists at the repo root | _(auto)_ | | `NW_MCP_TRANSPORT` | MCP transport mode (`stdio` or `streamable-http`) | `stdio` | | `NW_MCP_HOST` | MCP streamable-http bind address | `127.0.0.1` | | `NW_MCP_PORT` | Port for streamable-http MCP | `8081` | diff --git a/docs/installation.md b/docs/installation.md index 2eda4aa..b9da6b4 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + # Installation Guide ## Prerequisites @@ -16,8 +20,8 @@ ### 1. Clone the repository ```bash -git clone -cd +git clone https://github.com/AOT-Technologies/node-wire.git +cd node-wire ``` ### 2. Configure @@ -57,9 +61,11 @@ uv lock ### 4. Verify the installation ```bash -uv run node-wire --help +uv run python -c "from importlib.metadata import version; print('node-wire', version('node-wire'))" ``` +To confirm the REST API starts, run `MODE=API uv run node-wire` and open `http://127.0.0.1:8000/health` (default bind is `127.0.0.1`; override with `NW_REST_HOST` if needed). + --- ## Running the Platform diff --git a/docs/mcp-client-oauth.md b/docs/mcp-client-oauth.md index e00e338..7ee709c 100644 --- a/docs/mcp-client-oauth.md +++ b/docs/mcp-client-oauth.md @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + # MCP Client OAuth (Outbound) Node Wire can act as an **OAuth 2.1 client** when connecting to **remote HTTP MCP servers** that require authorization per the MCP Authorization specification (2025-11-25). diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index 232fe20..55d716c 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -362,7 +362,7 @@ FROM_EMAIL=your-email@gmail.com | `STRIPE_API_KEY` | Your Stripe secret API key (starts with `sk_test_` or `sk_live_`) | ```env -STRIPE_API_KEY=sk_test_4eC39HqLyjWDarjtT1zdp7dc +STRIPE_API_KEY=sk_test_your_secret_key_here ``` #### `nw-salesforce` diff --git a/docs/mcp.md b/docs/mcp.md index 0f4f22d..731275b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + # Model Context Protocol (MCP) in Node Wire Node Wire integrates with the Model Context Protocol to allow AI agents (like Claude or custom LLM orchestrators) to discover and use connectors as tools. diff --git a/docs/packaging.md b/docs/packaging.md index a35933d..7fa464f 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -26,8 +26,6 @@ Node Wire ships as **nine independent PyPI packages** (the runtime plus eight co Each connector's `pyproject.toml` lives at `packages/connectors//pyproject.toml`; the runtime's is at `packages/runtime/pyproject.toml`. -> **Note:** `salesforce` and `slack` build locally via `scripts/build-packages.sh` but are **not yet in the `.github/workflows/publish.yml` allowlist** (which currently publishes seven packages), so they do not auto-publish through that workflow. - --- ## Python package build lifecycle @@ -122,7 +120,7 @@ At startup, `auto_register()` discovers all installed connectors via the `node_w | Env var | Default | Purpose | |---|---|---| -| `NW_ALLOWED_CONNECTORS` | _(all discovered)_ | Comma-separated allowlist of entry-point names (e.g. `stripe,fhir_epic`). In development, leave unset to load everything. In production, set explicitly. | +| `NW_ALLOWED_CONNECTORS` | _(empty — load nothing)_ | Comma-separated allowlist of entry-point names (e.g. `stripe,fhir_epic`). **Unset or empty loads no connectors** (fail-closed). Set explicitly in production and local `.env`. | | `NW_CONNECTOR_MODULE_PREFIX` | `node_wire_` | Connectors whose target module doesn't start with this prefix are skipped with a warning. Set to `""` to disable the check. | --- diff --git a/docs/privacy.md b/docs/privacy.md index 8395039..b39f399 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -1,14 +1,18 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + # Privacy Policy and Compliance -The Node-Wire project is committed to ensuring privacy and secure data handling out-of-the-box. As a framework facilitating the orchestration of integrations between Large Language Models (LLMs) and various enterprise/healthcare systems, Node-Wire adheres to strict principles to prevent inadvertent data exposure. +The Node Wire project is committed to ensuring privacy and secure data handling out-of-the-box. As a framework facilitating the orchestration of integrations between Large Language Models (LLMs) and various enterprise/healthcare systems, Node Wire adheres to strict principles to prevent inadvertent data exposure. ## Core Privacy Principles 1. **No Telemetry or Phone Home:** - The Node-Wire open-source framework does not collect, transmit, or store any usage data, telemetry, or analytics. It operates entirely within the infrastructure where it is deployed. + The Node Wire open-source framework does not collect, transmit, or store any usage data, telemetry, or analytics. It operates entirely within the infrastructure where it is deployed. 2. **No Data Persistence by Default:** - Node-Wire acts as an orchestration and routing layer. It does not contain a built-in database for persistent storage of transaction data, logs, or payloads. Any data persistence must be explicitly configured by the user via connectors (e.g., storing a file in Google Drive). + Node Wire acts as an orchestration and routing layer. It does not contain a built-in database for persistent storage of transaction data, logs, or payloads. Any data persistence must be explicitly configured by the user via connectors (e.g., storing a file in Google Drive). 3. **Zero PII/PHI in Source Control:** The repository is routinely audited to ensure no Personally Identifiable Information (PII) or Protected Health Information (PHI) is committed to source control. @@ -21,14 +25,14 @@ All unit tests, integration tests, and example scenarios within the `tests/` and - **Dummy Patient IDs:** `12724066`, `eXYZ123` - **Dummy Credentials:** Credentials in tests use explicit `dummy` or `test` prefixes (e.g., `sk_test_dummy`). -If you are contributing to Node-Wire, you **must** ensure that no real data from your environment is included in your commits. +If you are contributing to Node Wire, you **must** ensure that no real data from your environment is included in your commits. ## Logging -By default, Node-Wire logging is configured to provide operational visibility without exposing sensitive payloads. However, when running the MCP Server or REST API in `DEBUG` mode, certain raw HTTP requests and responses may be logged for troubleshooting. +By default, Node Wire logging is configured to provide operational visibility without exposing sensitive payloads. However, when running the MCP Server or REST API in `DEBUG` mode, certain raw HTTP requests and responses may be logged for troubleshooting. -**Guidance:** Do not run Node-Wire in `DEBUG` logging mode in production environments to prevent the accidental leakage of sensitive data into system logs. +**Guidance:** Do not run Node Wire in `DEBUG` logging mode in production environments to prevent the accidental leakage of sensitive data into system logs. ## Security Disclosures -If you discover a potential privacy or security vulnerability within Node-Wire, please do not disclose it publicly. Refer to our [Security Policy](../SECURITY.md) for instructions on how to securely report issues to the maintainers. +If you discover a potential privacy or security vulnerability within Node Wire, please do not disclose it publicly. Refer to our [Security Policy](../SECURITY.md) for instructions on how to securely report issues to the maintainers. diff --git a/docs/quality-security-gates.md b/docs/quality-security-gates.md index b78b190..11b2d48 100644 --- a/docs/quality-security-gates.md +++ b/docs/quality-security-gates.md @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + # Quality and security gates This document defines how Node Wire enforces security scanning and SonarQube analysis in CI, plus the SonarQube Community Edition setup required for centralized reporting. @@ -31,6 +35,8 @@ Required checks to add in branch protection: - `Python package security PR checks / Vulnerability scan (packages/connectors/google_drive)` - `Python package security PR checks / Vulnerability scan (packages/connectors/fhir_cerner)` - `Python package security PR checks / Vulnerability scan (packages/connectors/fhir_epic)` +- `Python package security PR checks / Vulnerability scan (packages/connectors/salesforce)` +- `Python package security PR checks / Vulnerability scan (packages/connectors/slack)` Configure branch protection so pull requests cannot merge unless all required checks pass. diff --git a/docs/salesforce_connector.md b/docs/salesforce_connector.md index 264c107..56db076 100644 --- a/docs/salesforce_connector.md +++ b/docs/salesforce_connector.md @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + # Salesforce Connector (`src/node_wire_salesforce`) The Salesforce connector provides a secure, asynchronous interface for managing CRM records (Leads and Contacts). It leverages Node Wire's `OAuth2AuthProvider` to handle token refresh automatically, allowing for seamless integration into agentic workflows and medical-to-CRM pipelines. diff --git a/docs/slack_connector.md b/docs/slack_connector.md index e69c4a5..de05f88 100644 --- a/docs/slack_connector.md +++ b/docs/slack_connector.md @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + # Slack Connector This document covers the Slack connector under `src/node_wire_slack` in two parts: diff --git a/docs/toolhive_agent_scenario.md b/docs/toolhive_agent_scenario.md index 7b2e58b..42d4da0 100644 --- a/docs/toolhive_agent_scenario.md +++ b/docs/toolhive_agent_scenario.md @@ -143,7 +143,7 @@ Below is the full set of environment variables used by the connector platform an | `FROM_EMAIL` | Email sending | Example: `from@example.com` | | `GROQ_API_KEY` | LLM (Groq) | Your Groq API key | | `GROQ_MODEL` | LLM | Example: `openai/gpt-oss-120b` | -| `MCP_TRANSPORT` | ToolHive / local | `stdio` when running in ToolHive container | +| `NW_MCP_TRANSPORT` | ToolHive / local | `stdio` when running in ToolHive container | | `PYTHONPATH` | Runtime | e.g. `/app/src` for container; `**/node-wire/src` locally | | `SMTP_HOST` | SMTP connector | Example: `sandbox.smtp.mailtrap.io` | | `SMTP_PORT` | SMTP connector | Example: `2525` | @@ -210,7 +210,13 @@ Notes for non-developers: ## Step 1: Build the Docker image -From the root of the repository: +From the root of the repository, build the Python wheels first (required for the Docker image): + +```bash +bash scripts/build-packages.sh +``` + +Then build the container image: ```bash docker build -t node-wire:latest . diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b864f5d..4693067 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + # Troubleshooting Guide ## Common Errors & Fixes diff --git a/grafana/README.md b/grafana/README.md index 4ebcae4..254e2a6 100644 --- a/grafana/README.md +++ b/grafana/README.md @@ -9,7 +9,7 @@ SPDX-License-Identifier: Apache-2.0 ## What is included - `docker-compose.yml` runs `grafana/otel-lgtm` (Grafana + Loki + OTLP endpoints). -- `Connector Logs & Status - Updated-1773917850709.json` is the dashboard export you can import. +- A sample dashboard JSON is not shipped in this repository; create panels from Loki logs or export your own dashboard after wiring the stack. - Exposed ports: - `3000` -> Grafana UI - `4317` -> OTLP gRPC ingest @@ -34,11 +34,11 @@ docker compose down 1. Open `http://localhost:3000`. 2. If Grafana asks for a datasource during import, choose `Loki` (UID is usually `loki` in this stack). -## Import the dashboard JSON +## Import or build a dashboard -1. In Grafana, go to **Dashboards** -> **Import**. -2. Upload `Connector Logs & Status - Updated-1773917850709.json`. -3. Map the datasource to **Loki** if prompted. +1. In Grafana, go to **Dashboards** -> **New** -> **Import** (or build panels manually). +2. Choose **Loki** as the datasource (UID is usually `loki` in this stack). +3. Query connector logs with labels such as `connector_type` and `status`. 4. Save the dashboard. ## Monitor the dashboard diff --git a/grafana/docker-compose.yml b/grafana/docker-compose.yml index 2e2e20a..c7bc279 100644 --- a/grafana/docker-compose.yml +++ b/grafana/docker-compose.yml @@ -1,13 +1,13 @@ -## -## SPDX-FileCopyrightText: 2026 AOT Technologies -## SPDX-License-Identifier: Apache-2.0 -## -services: - otel-lgtm: - image: grafana/otel-lgtm - ports: - - "3000:3000" - - "4317:4317" - - "4318:4318" - stdin_open: true +## +## SPDX-FileCopyrightText: 2026 AOT Technologies +## SPDX-License-Identifier: Apache-2.0 +## +services: + otel-lgtm: + image: grafana/otel-lgtm + ports: + - "3000:3000" + - "4317:4317" + - "4318:4318" + stdin_open: true tty: true diff --git a/packages/connectors/fhir_cerner/__init__.py b/packages/connectors/fhir_cerner/__init__.py index e69de29..39bdade 100644 --- a/packages/connectors/fhir_cerner/__init__.py +++ b/packages/connectors/fhir_cerner/__init__.py @@ -0,0 +1,4 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# diff --git a/packages/connectors/fhir_cerner/pyproject.toml b/packages/connectors/fhir_cerner/pyproject.toml index 53d715a..126a01b 100644 --- a/packages/connectors/fhir_cerner/pyproject.toml +++ b/packages/connectors/fhir_cerner/pyproject.toml @@ -7,7 +7,8 @@ name = "node-wire-fhir-cerner" version = "0.1.0" description = "Node Wire connector — Cerner FHIR R4 (read/search patients and encounters)" requires-python = ">=3.11" -authors = [{ name = "AOT", email = "dev@aot.local" }] +license = "Apache-2.0" +authors = [{ name = "AOT Technologies", email = "opensource@aot-technologies.com" }] dependencies = [ "node-wire-runtime>=0.1.0", diff --git a/packages/connectors/fhir_epic/__init__.py b/packages/connectors/fhir_epic/__init__.py index e69de29..39bdade 100644 --- a/packages/connectors/fhir_epic/__init__.py +++ b/packages/connectors/fhir_epic/__init__.py @@ -0,0 +1,4 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# diff --git a/packages/connectors/fhir_epic/pyproject.toml b/packages/connectors/fhir_epic/pyproject.toml index db0987b..2992a6b 100644 --- a/packages/connectors/fhir_epic/pyproject.toml +++ b/packages/connectors/fhir_epic/pyproject.toml @@ -7,7 +7,8 @@ name = "node-wire-fhir-epic" version = "0.1.0" description = "Node Wire connector — Epic FHIR R4 (read/search patients and encounters)" requires-python = ">=3.11" -authors = [{ name = "AOT", email = "dev@aot.local" }] +license = "Apache-2.0" +authors = [{ name = "AOT Technologies", email = "opensource@aot-technologies.com" }] dependencies = [ "node-wire-runtime>=0.1.0", diff --git a/packages/connectors/google_drive/__init__.py b/packages/connectors/google_drive/__init__.py index e69de29..39bdade 100644 --- a/packages/connectors/google_drive/__init__.py +++ b/packages/connectors/google_drive/__init__.py @@ -0,0 +1,4 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# diff --git a/packages/connectors/google_drive/pyproject.toml b/packages/connectors/google_drive/pyproject.toml index c356768..550d613 100644 --- a/packages/connectors/google_drive/pyproject.toml +++ b/packages/connectors/google_drive/pyproject.toml @@ -7,7 +7,8 @@ name = "node-wire-google-drive" version = "0.1.0" description = "Node Wire connector — Google Drive API v3 (files and permissions)" requires-python = ">=3.11" -authors = [{ name = "AOT", email = "dev@aot.local" }] +license = "Apache-2.0" +authors = [{ name = "AOT Technologies", email = "opensource@aot-technologies.com" }] dependencies = [ "node-wire-runtime>=0.1.0", diff --git a/packages/connectors/http_generic/__init__.py b/packages/connectors/http_generic/__init__.py index e69de29..39bdade 100644 --- a/packages/connectors/http_generic/__init__.py +++ b/packages/connectors/http_generic/__init__.py @@ -0,0 +1,4 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# diff --git a/packages/connectors/http_generic/pyproject.toml b/packages/connectors/http_generic/pyproject.toml index 0786852..29c691c 100644 --- a/packages/connectors/http_generic/pyproject.toml +++ b/packages/connectors/http_generic/pyproject.toml @@ -7,7 +7,8 @@ name = "node-wire-http-generic" version = "0.1.0" description = "Node Wire connector — generic HTTP REST client (GET/POST/PUT/DELETE/PATCH)" requires-python = ">=3.11" -authors = [{ name = "AOT", email = "dev@aot.local" }] +license = "Apache-2.0" +authors = [{ name = "AOT Technologies", email = "opensource@aot-technologies.com" }] dependencies = [ "node-wire-runtime>=0.1.0", diff --git a/packages/connectors/salesforce/pyproject.toml b/packages/connectors/salesforce/pyproject.toml index d13034b..1c107e1 100644 --- a/packages/connectors/salesforce/pyproject.toml +++ b/packages/connectors/salesforce/pyproject.toml @@ -1,13 +1,18 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + [project] name = "node-wire-salesforce" version = "0.1.0" description = "Node Wire connector — Salesforce CRM" requires-python = ">=3.11" -authors = [{ name = "AOT", email = "dev@aot.local" }] +license = "Apache-2.0" +authors = [{ name = "AOT Technologies", email = "opensource@aot-technologies.com" }] dependencies = [ "node-wire-runtime>=0.1.0", - "httpx>=0.27.0", + "httpx[http2]>=0.27.0,<0.28.0", ] [project.entry-points."node_wire.connectors"] diff --git a/packages/connectors/salesforce/setup.py b/packages/connectors/salesforce/setup.py index a6ba329..0fe425f 100644 --- a/packages/connectors/salesforce/setup.py +++ b/packages/connectors/salesforce/setup.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# import glob import os from Cython.Build import cythonize diff --git a/packages/connectors/slack/pyproject.toml b/packages/connectors/slack/pyproject.toml index 199859e..7b494e3 100644 --- a/packages/connectors/slack/pyproject.toml +++ b/packages/connectors/slack/pyproject.toml @@ -1,13 +1,18 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + [project] name = "node-wire-slack" version = "0.1.0" description = "Node Wire connector — Slack API (messaging and file uploads)" requires-python = ">=3.11" -authors = [{ name = "AOT", email = "dev@aot.local" }] +license = "Apache-2.0" +authors = [{ name = "AOT Technologies", email = "opensource@aot-technologies.com" }] dependencies = [ "node-wire-runtime>=0.1.0", - "httpx>=0.27.0", + "httpx[http2]>=0.27.0,<0.28.0", ] [project.entry-points."node_wire.connectors"] diff --git a/packages/connectors/slack/setup.py b/packages/connectors/slack/setup.py index cbbd861..8243f17 100644 --- a/packages/connectors/slack/setup.py +++ b/packages/connectors/slack/setup.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# import glob import os from Cython.Build import cythonize diff --git a/packages/connectors/smtp/__init__.py b/packages/connectors/smtp/__init__.py index e69de29..39bdade 100644 --- a/packages/connectors/smtp/__init__.py +++ b/packages/connectors/smtp/__init__.py @@ -0,0 +1,4 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# diff --git a/packages/connectors/smtp/pyproject.toml b/packages/connectors/smtp/pyproject.toml index 390557c..b3a539c 100644 --- a/packages/connectors/smtp/pyproject.toml +++ b/packages/connectors/smtp/pyproject.toml @@ -7,7 +7,8 @@ name = "node-wire-smtp" version = "0.1.0" description = "Node Wire connector — SMTP email sending (async)" requires-python = ">=3.11" -authors = [{ name = "AOT", email = "dev@aot.local" }] +license = "Apache-2.0" +authors = [{ name = "AOT Technologies", email = "opensource@aot-technologies.com" }] dependencies = [ "node-wire-runtime>=0.1.0", diff --git a/packages/connectors/stripe/__init__.py b/packages/connectors/stripe/__init__.py index e69de29..39bdade 100644 --- a/packages/connectors/stripe/__init__.py +++ b/packages/connectors/stripe/__init__.py @@ -0,0 +1,4 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# diff --git a/packages/connectors/stripe/pyproject.toml b/packages/connectors/stripe/pyproject.toml index d511635..3ec298f 100644 --- a/packages/connectors/stripe/pyproject.toml +++ b/packages/connectors/stripe/pyproject.toml @@ -7,7 +7,8 @@ name = "node-wire-stripe" version = "0.1.0" description = "Node Wire connector — Stripe payments" requires-python = ">=3.11" -authors = [{ name = "AOT", email = "dev@aot.local" }] +license = "Apache-2.0" +authors = [{ name = "AOT Technologies", email = "opensource@aot-technologies.com" }] dependencies = [ "node-wire-runtime>=0.1.0", diff --git a/packages/runtime/README.md b/packages/runtime/README.md new file mode 100644 index 0000000..585872c --- /dev/null +++ b/packages/runtime/README.md @@ -0,0 +1,11 @@ + + +# node-wire-runtime + +Core Node Wire runtime: connector framework, resilience, observability, secret providers, and MCP scope policies. + +Install from PyPI as `node-wire-runtime`, or from this monorepo via `packages/runtime`. diff --git a/packages/runtime/pyproject.toml b/packages/runtime/pyproject.toml index cf477ff..110ada8 100644 --- a/packages/runtime/pyproject.toml +++ b/packages/runtime/pyproject.toml @@ -7,7 +7,8 @@ name = "node-wire-runtime" version = "0.1.0" description = "Node Wire runtime — connector framework, resilience, observability, and pluggable secrets" requires-python = ">=3.11" -authors = [{ name = "AOT", email = "dev@aot.local" }] +license = "Apache-2.0" +authors = [{ name = "AOT Technologies", email = "opensource@aot-technologies.com" }] readme = "README.md" dependencies = [ diff --git a/playground/ext_patient_viewer/__init__.py b/playground/ext_patient_viewer/__init__.py index 77a99ae..8767888 100644 --- a/playground/ext_patient_viewer/__init__.py +++ b/playground/ext_patient_viewer/__init__.py @@ -1 +1,6 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# + # External Patient Viewer (Read-Only Retrieval) - playground sub-module diff --git a/playground/ext_patient_viewer/schema.py b/playground/ext_patient_viewer/schema.py index 6e9be22..365de07 100644 --- a/playground/ext_patient_viewer/schema.py +++ b/playground/ext_patient_viewer/schema.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """ External Patient Viewer — Pydantic input/output schemas. diff --git a/pyproject.toml b/pyproject.toml index db80cb1..1fea8b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ name = "node-wire" version = "0.1.0" description = "Node Wire — runtime, connectors, and bindings" +license = "Apache-2.0" requires-python = ">=3.11" authors = [ { name = "AOT Technologies", email = "opensource@aot-technologies.com" }, @@ -15,7 +16,6 @@ keywords = ["connectors", "mcp", "rest", "grpc", "integration", "runtime"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -57,6 +57,10 @@ node-wire = "bindings_entrypoint:main" nw-google-drive = "agents.google_drive_mcp:main" nw-smartonfhir-epic = "agents.fhir_epic_mcp:main" nw-smartonfhir-cerner = "agents.fhir_cerner_mcp:main" +nw-smtp = "agents.smtp_mcp:main" +nw-stripe = "agents.stripe_mcp:main" +nw-salesforce = "agents.salesforce_mcp:main" +nw-slack = "agents.slack_mcp:main" [project.optional-dependencies] dev = [ @@ -111,10 +115,15 @@ dev = [ "pre-commit>=4.0.0", "pytest-playwright>=0.4.0", "cyclonedx-bom==4.6.1", + "reuse>=5.0.0", + "licenseheaders>=0.8.8", ] [tool.uv] default-groups = ["dev"] +override-dependencies = [ + "lxml>=6.1.0", +] [tool.pytest.ini_options] pythonpath = ["src", "."] @@ -141,6 +150,10 @@ omit = [ [tool.ruff] target-version = "py311" line-length = 100 +extend-exclude = [ + "*_pb2.py", + "*_pb2_grpc.py", +] [tool.mypy] python_version = "3.11" @@ -151,6 +164,7 @@ ignore_missing_imports = true files = ["src"] exclude = [ ".*packages/.*/setup\\.py", + ".*_pb2(_grpc)?\\.py", ] [[tool.mypy.overrides]] module = "playground.*" diff --git a/sample.env b/sample.env index 6ae73af..a09c535 100644 --- a/sample.env +++ b/sample.env @@ -115,7 +115,7 @@ ANTHROPIC_MODEL=claude-3-5-haiku-20241022 # For production, omit this and set NW_MCP_API_KEY (and/or NW_MCP_JWT_SECRET). # (The legacy NW_MCP_AUTH_ENABLED flag is deprecated; it now honours its literal # meaning — NW_MCP_AUTH_ENABLED=false disables auth — and logs a warning.) -NW_MCP_AUTH_DISABLED=true +# NW_MCP_AUTH_DISABLED=true NW_MCP_API_KEY=replace-with-strong-random-value # API key scopes (JSON array or space/comma-separated). Empty = no scopes; use "*" only for explicit full access. # Wildcard API keys intentionally bypass per-action scope checks. @@ -143,7 +143,7 @@ NW_MCP_SCOPE_POLICY_DEFAULT=deny # TOOLHIVE_MCP_BEARER_TOKEN= # REST auth for Playground demo (disable for local UI testing) -NW_REST_AUTH_DISABLED=true +# NW_REST_AUTH_DISABLED=true NW_REST_LOAD_DOTENV=true # REST API key scopes (same format as NW_MCP_API_KEY_SCOPES). Empty = no scopes unless JWT carries scopes. # NW_REST_API_KEY_SCOPES=["mcp:smtp.send_email"] diff --git a/scripts/bandit_report_summary.py b/scripts/bandit_report_summary.py index 2feb2cb..47a80d5 100644 --- a/scripts/bandit_report_summary.py +++ b/scripts/bandit_report_summary.py @@ -1,4 +1,8 @@ #!/usr/bin/env python3 +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """Print a concise Bandit JSON report summary for CI logs (always exits 0). Bandit exits with a non-zero status when *any* severity finding exists, even if diff --git a/scripts/build-mcp-images.sh b/scripts/build-mcp-images.sh index d193330..3dc94df 100755 --- a/scripts/build-mcp-images.sh +++ b/scripts/build-mcp-images.sh @@ -21,6 +21,7 @@ Images: - nw-smartonfhir-cerner - nw-smtp - nw-stripe + - nw-salesforce - nw-slack EOF } diff --git a/scripts/demo_mcp_oauth_mock.py b/scripts/demo_mcp_oauth_mock.py index 645e3dc..c1c35df 100644 --- a/scripts/demo_mcp_oauth_mock.py +++ b/scripts/demo_mcp_oauth_mock.py @@ -1,123 +1,125 @@ -# Copyright 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -"""Internal demo: MCP OAuth phases 0-4 with mock issuer (no real network).""" - -from __future__ import annotations - -import asyncio -import tempfile -from pathlib import Path - -import httpx - -from node_wire_runtime.mcp_client import ( - AuthorizationCodeFlow, - DiscoveryCache, - McpClientConfig, - McpServerConfig, - RegistrationStore, - TokenManager, - discover, - resolve_client_registration, -) -from node_wire_runtime.mcp_client.redirect_listener import AuthorizationCallback -from node_wire_runtime.mcp_client.token_storage import InMemoryTokenStore - -MCP_URL = "https://mcp.example.com/mcp" -ISSUER = "https://issuer.example" - -PRM = {"resource": MCP_URL, "authorization_servers": [ISSUER]} -AS_META = { - "issuer": ISSUER, - "authorization_endpoint": f"{ISSUER}/authorize", - "token_endpoint": f"{ISSUER}/token", - "registration_endpoint": f"{ISSUER}/register", -} - - -def mock_handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - if "oauth-protected-resource" in path: - return httpx.Response(200, json=PRM) - if "oauth-authorization-server" in path: - return httpx.Response(200, json=AS_META) - if path.endswith("/register"): - return httpx.Response(201, json={"client_id": "demo-mcp-client"}) - if path.endswith("/token"): - form = request.read().decode() - assert "resource=" in form - return httpx.Response( - 200, - json={ - "access_token": "demo-access-token", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "demo-refresh", - }, - ) - return httpx.Response(404) - - -def section(title: str) -> None: - print("\n" + "=" * 60) - print(title) - print("=" * 60) - - -async def main() -> None: - section("1. Configuration") - config = McpClientConfig(server=McpServerConfig(url=MCP_URL)) - print(" MCP server:", config.canonical_server_url) - - transport = httpx.MockTransport(mock_handler) - async with httpx.AsyncClient(transport=transport) as http: - section("2. Discovery") - discovery = await discover(config, cache=DiscoveryCache(3600), http_client=http) - print(" Issuer:", discovery.issuer) - - section("3. DCR") - with tempfile.TemporaryDirectory() as tmp: - store = RegistrationStore(Path(tmp)) - reg = await resolve_client_registration( - config, - discovery, - redirect_uris=["http://127.0.0.1:9999/callback"], - store=store, - http_client=http, - ) - print(" client_id:", reg.client_id) - - section("4. Authorization (simulated)") - flow = AuthorizationCodeFlow(config, discovery=discovery, registration=reg) - session, url = await flow.prepare_authorization_session( - redirect_uri="http://127.0.0.1:9999/callback" - ) - print(" Authorize URL contains resource:", "resource=" in url) - callback = AuthorizationCallback( - code="demo-code", - state=session.state, - error=None, - error_description=None, - ) - code = flow.validate_callback(callback, expected_state=session.state) - tokens = await flow.exchange_code(code, session=session, http_client=http) - print(" access_token length:", len(tokens.access_token)) - - section("5. Token manager") - mgr = TokenManager( - config, - user_id="demo-user", - token_store=InMemoryTokenStore(), - discovery=discovery, - registration=reg, - auth_flow=flow, - ) - mgr.persist_oauth_token_set(tokens, issuer=discovery.issuer) - bearer = await mgr.get_bearer_token(http_client=http) - print(" Bearer ready, length:", len(bearer)) - - section("Done") - - -if __name__ == "__main__": - asyncio.run(main()) +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +"""Internal demo: MCP OAuth phases 0-4 with mock issuer (no real network).""" + +from __future__ import annotations + +import asyncio +import tempfile +from pathlib import Path + +import httpx + +from node_wire_runtime.mcp_client import ( + AuthorizationCodeFlow, + DiscoveryCache, + McpClientConfig, + McpServerConfig, + RegistrationStore, + TokenManager, + discover, + resolve_client_registration, +) +from node_wire_runtime.mcp_client.redirect_listener import AuthorizationCallback +from node_wire_runtime.mcp_client.token_storage import InMemoryTokenStore + +MCP_URL = "https://mcp.example.com/mcp" +ISSUER = "https://issuer.example" + +PRM = {"resource": MCP_URL, "authorization_servers": [ISSUER]} +AS_META = { + "issuer": ISSUER, + "authorization_endpoint": f"{ISSUER}/authorize", + "token_endpoint": f"{ISSUER}/token", + "registration_endpoint": f"{ISSUER}/register", +} + + +def mock_handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if "oauth-protected-resource" in path: + return httpx.Response(200, json=PRM) + if "oauth-authorization-server" in path: + return httpx.Response(200, json=AS_META) + if path.endswith("/register"): + return httpx.Response(201, json={"client_id": "demo-mcp-client"}) + if path.endswith("/token"): + form = request.read().decode() + assert "resource=" in form + return httpx.Response( + 200, + json={ + "access_token": "demo-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "demo-refresh", + }, + ) + return httpx.Response(404) + + +def section(title: str) -> None: + print("\n" + "=" * 60) + print(title) + print("=" * 60) + + +async def main() -> None: + section("1. Configuration") + config = McpClientConfig(server=McpServerConfig(url=MCP_URL)) + print(" MCP server:", config.canonical_server_url) + + transport = httpx.MockTransport(mock_handler) + async with httpx.AsyncClient(transport=transport) as http: + section("2. Discovery") + discovery = await discover(config, cache=DiscoveryCache(3600), http_client=http) + print(" Issuer:", discovery.issuer) + + section("3. DCR") + with tempfile.TemporaryDirectory() as tmp: + store = RegistrationStore(Path(tmp)) + reg = await resolve_client_registration( + config, + discovery, + redirect_uris=["http://127.0.0.1:9999/callback"], + store=store, + http_client=http, + ) + print(" client_id:", reg.client_id) + + section("4. Authorization (simulated)") + flow = AuthorizationCodeFlow(config, discovery=discovery, registration=reg) + session, url = await flow.prepare_authorization_session( + redirect_uri="http://127.0.0.1:9999/callback" + ) + print(" Authorize URL contains resource:", "resource=" in url) + callback = AuthorizationCallback( + code="demo-code", + state=session.state, + error=None, + error_description=None, + ) + code = flow.validate_callback(callback, expected_state=session.state) + tokens = await flow.exchange_code(code, session=session, http_client=http) + print(" access_token length:", len(tokens.access_token)) + + section("5. Token manager") + mgr = TokenManager( + config, + user_id="demo-user", + token_store=InMemoryTokenStore(), + discovery=discovery, + registration=reg, + auth_flow=flow, + ) + mgr.persist_oauth_token_set(tokens, issuer=discovery.issuer) + bearer = await mgr.get_bearer_token(http_client=http) + print(" Bearer ready, length:", len(bearer)) + + section("Done") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/generate-grpc-stubs.sh b/scripts/generate-grpc-stubs.sh new file mode 100755 index 0000000..1674a65 --- /dev/null +++ b/scripts/generate-grpc-stubs.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +## +## SPDX-FileCopyrightText: 2026 AOT Technologies +## SPDX-License-Identifier: Apache-2.0 +## + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +PROTO_DIR="src/bindings/grpc_server" +PROTO_FILE="${PROTO_DIR}/connector.proto" + +if command -v uv >/dev/null 2>&1; then + PYTHON=(uv run python) +else + PYTHON=(python3) +fi + +"${PYTHON[@]}" -m grpc_tools.protoc \ + -I "${PROTO_DIR}" \ + --python_out="${PROTO_DIR}" \ + --grpc_python_out="${PROTO_DIR}" \ + "${PROTO_FILE}" + +# Ensure package-relative import in generated servicer module. +if grep -q '^import connector_pb2 as connector__pb2' "${PROTO_DIR}/connector_pb2_grpc.py"; then + sed -i.bak 's/^import connector_pb2 as connector__pb2/from . import connector_pb2 as connector__pb2/' \ + "${PROTO_DIR}/connector_pb2_grpc.py" + rm -f "${PROTO_DIR}/connector_pb2_grpc.py.bak" +fi + +echo "PASS: gRPC stubs generated in ${PROTO_DIR}" +# Generated *_pb2*.py files are excluded from Ruff and Mypy in pyproject.toml. diff --git a/scripts/run-compliance-checks.sh b/scripts/run-compliance-checks.sh index 4acebc6..1033be1 100644 --- a/scripts/run-compliance-checks.sh +++ b/scripts/run-compliance-checks.sh @@ -1,9 +1,8 @@ #!/usr/bin/env bash -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -# Runs security, dependency, and open-source compliance checks. +## +## SPDX-FileCopyrightText: 2026 AOT Technologies +## SPDX-License-Identifier: Apache-2.0 +## set -e @@ -19,6 +18,10 @@ echo "1. Generating DEPENDENCIES.md..." echo "=====================================" cat << 'EOF' > DEPENDENCIES.md +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + # Node Wire Open Source Dependencies This file is automatically generated and contains an inventory of all third-party dependencies used in the Node Wire project. @@ -48,13 +51,19 @@ echo "=====================================" echo "3. Running Bandit (SAST Scanner)..." echo "=====================================" # We allow medium/low severity but want to output findings. -uv run bandit -r src/ packages/ playground/ tests/ -ll || true +uv run bandit -c pyproject.toml -r src/ --severity-level high echo "" echo "=====================================" echo "4. Running pip-audit (Vulnerability Scanner)..." echo "=====================================" -uv run pip-audit || true +uv run pip-audit + +echo "" +echo "=====================================" +echo "5. Running REUSE lint..." +echo "=====================================" +uv run reuse lint echo "" echo "Compliance checks finished!" diff --git a/sonar-project.properties b/sonar-project.properties index c530b7d..860a72d 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 + sonar.projectKey=node-wire sonar.projectName=Node Wire sonar.sourceEncoding=UTF-8 diff --git a/src/agents/salesforce_mcp.py b/src/agents/salesforce_mcp.py index 31f8669..4fcd7b6 100644 --- a/src/agents/salesforce_mcp.py +++ b/src/agents/salesforce_mcp.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """MCP Server — Salesforce connector only. Usage: python -m agents.salesforce_mcp""" from __future__ import annotations diff --git a/src/agents/slack_mcp.py b/src/agents/slack_mcp.py index 4851521..edec20d 100644 --- a/src/agents/slack_mcp.py +++ b/src/agents/slack_mcp.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """MCP Server — Slack connector only. Usage: python -m agents.slack_mcp""" from __future__ import annotations diff --git a/src/agents/stripe_mcp.py b/src/agents/stripe_mcp.py index 574c00f..99aba6b 100644 --- a/src/agents/stripe_mcp.py +++ b/src/agents/stripe_mcp.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """MCP Server — Stripe connector only. Usage: python -m agents.stripe_mcp""" from __future__ import annotations diff --git a/src/bindings/__init__.py b/src/bindings/__init__.py index c11239c..4e3782a 100644 --- a/src/bindings/__init__.py +++ b/src/bindings/__init__.py @@ -1,15 +1,15 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -from __future__ import annotations - -""" -Node Wire - Layer C: Platform bindings. - -This package contains: -- ConnectorFactory for instantiating connectors from configuration -- REST API binding (FastAPI) -- gRPC server binding -- MCP server binding for AI agents -""" +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +""" +Node Wire - Layer C: Platform bindings. + +This package contains: +- ConnectorFactory for instantiating connectors from configuration +- REST API binding (FastAPI) +- gRPC server binding +- MCP server binding for AI agents +""" diff --git a/src/bindings/grpc_server/__init__.py b/src/bindings/grpc_server/__init__.py new file mode 100644 index 0000000..3196f1c --- /dev/null +++ b/src/bindings/grpc_server/__init__.py @@ -0,0 +1,6 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# + +"""gRPC binding for Node Wire connector invocation.""" diff --git a/src/bindings/grpc_server/auth.py b/src/bindings/grpc_server/auth.py index f9a859b..7aa466a 100644 --- a/src/bindings/grpc_server/auth.py +++ b/src/bindings/grpc_server/auth.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """ gRPC API authentication (enterprise default: required API key or JWT). diff --git a/src/bindings/grpc_server/connector.proto b/src/bindings/grpc_server/connector.proto index 3ab0bfa..f564191 100644 --- a/src/bindings/grpc_server/connector.proto +++ b/src/bindings/grpc_server/connector.proto @@ -1,24 +1,24 @@ -// SPDX-FileCopyrightText: 2026 AOT Technologies -// SPDX-License-Identifier: Apache-2.0 -syntax = "proto3"; - -package aot.connectors; - -service ConnectorService { - rpc Invoke (InvokeRequest) returns (InvokeResponse); -} - -message InvokeRequest { - string connector_id = 1; - string action = 2; - string payload_json = 3; -} - -message InvokeResponse { - bool success = 1; - string data_json = 2; - string error_code = 3; - string error_category = 4; - string message = 5; - string trace_id = 6; -} +// SPDX-FileCopyrightText: 2026 AOT Technologies +// SPDX-License-Identifier: Apache-2.0 +syntax = "proto3"; + +package aot.connectors; + +service ConnectorService { + rpc Invoke (InvokeRequest) returns (InvokeResponse); +} + +message InvokeRequest { + string connector_id = 1; + string action = 2; + string payload_json = 3; +} + +message InvokeResponse { + bool success = 1; + string data_json = 2; + string error_code = 3; + string error_category = 4; + string message = 5; + string trace_id = 6; +} diff --git a/src/bindings/grpc_server/connector_pb2.py b/src/bindings/grpc_server/connector_pb2.py new file mode 100644 index 0000000..87e0fb6 --- /dev/null +++ b/src/bindings/grpc_server/connector_pb2.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# + +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: connector.proto +# Protobuf Python Version: 5.29.0 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 5, + 29, + 0, + '', + 'connector.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0f\x63onnector.proto\x12\x0e\x61ot.connectors\"K\n\rInvokeRequest\x12\x14\n\x0c\x63onnector_id\x18\x01 \x01(\t\x12\x0e\n\x06\x61\x63tion\x18\x02 \x01(\t\x12\x14\n\x0cpayload_json\x18\x03 \x01(\t\"\x83\x01\n\x0eInvokeResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x11\n\tdata_json\x18\x02 \x01(\t\x12\x12\n\nerror_code\x18\x03 \x01(\t\x12\x16\n\x0e\x65rror_category\x18\x04 \x01(\t\x12\x0f\n\x07message\x18\x05 \x01(\t\x12\x10\n\x08trace_id\x18\x06 \x01(\t2[\n\x10\x43onnectorService\x12G\n\x06Invoke\x12\x1d.aot.connectors.InvokeRequest\x1a\x1e.aot.connectors.InvokeResponseb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'connector_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_INVOKEREQUEST']._serialized_start=35 + _globals['_INVOKEREQUEST']._serialized_end=110 + _globals['_INVOKERESPONSE']._serialized_start=113 + _globals['_INVOKERESPONSE']._serialized_end=244 + _globals['_CONNECTORSERVICE']._serialized_start=246 + _globals['_CONNECTORSERVICE']._serialized_end=337 +# @@protoc_insertion_point(module_scope) diff --git a/src/bindings/grpc_server/connector_pb2_grpc.py b/src/bindings/grpc_server/connector_pb2_grpc.py new file mode 100644 index 0000000..64fe11f --- /dev/null +++ b/src/bindings/grpc_server/connector_pb2_grpc.py @@ -0,0 +1,102 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# + +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +from . import connector_pb2 as connector__pb2 + +GRPC_GENERATED_VERSION = '1.71.2' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + f' but the generated code in connector_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class ConnectorServiceStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Invoke = channel.unary_unary( + '/aot.connectors.ConnectorService/Invoke', + request_serializer=connector__pb2.InvokeRequest.SerializeToString, + response_deserializer=connector__pb2.InvokeResponse.FromString, + _registered_method=True) + + +class ConnectorServiceServicer(object): + """Missing associated documentation comment in .proto file.""" + + def Invoke(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_ConnectorServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Invoke': grpc.unary_unary_rpc_method_handler( + servicer.Invoke, + request_deserializer=connector__pb2.InvokeRequest.FromString, + response_serializer=connector__pb2.InvokeResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'aot.connectors.ConnectorService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('aot.connectors.ConnectorService', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class ConnectorService(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def Invoke(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/aot.connectors.ConnectorService/Invoke', + connector__pb2.InvokeRequest.SerializeToString, + connector__pb2.InvokeResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/src/bindings/grpc_server/server.py b/src/bindings/grpc_server/server.py index af666ab..52cd335 100644 --- a/src/bindings/grpc_server/server.py +++ b/src/bindings/grpc_server/server.py @@ -35,12 +35,13 @@ def __init__(self) -> None: self._factory.load() async def _invoke_async( - self, request: connector_pb2.InvokeRequest - ) -> connector_pb2.InvokeResponse: # type: ignore[name-defined] + self, + request: connector_pb2.InvokeRequest, # type: ignore[name-defined, attr-defined] + ) -> connector_pb2.InvokeResponse: # type: ignore[name-defined, attr-defined] try: await global_rate_limiter.acquire() except RateLimitExceeded as e: - return connector_pb2.InvokeResponse( # type: ignore[name-defined] + return connector_pb2.InvokeResponse( # type: ignore[name-defined, attr-defined] success=False, error_code="RATE_LIMIT_EXCEEDED", error_category=ErrorCategory.RETRYABLE.value, @@ -50,7 +51,7 @@ async def _invoke_async( connector = self._factory.get_for_protocol(request.connector_id, "grpc") if connector is None: - return connector_pb2.InvokeResponse( # type: ignore[name-defined] + return connector_pb2.InvokeResponse( # type: ignore[name-defined, attr-defined] success=False, error_code="CONNECTOR_NOT_AVAILABLE", error_category=ErrorCategory.BUSINESS.value, @@ -63,7 +64,7 @@ async def _invoke_async( try: payload = json.loads(request.payload_json) except json.JSONDecodeError as e: - return connector_pb2.InvokeResponse( # type: ignore[name-defined] + return connector_pb2.InvokeResponse( # type: ignore[name-defined, attr-defined] success=False, error_code="INVALID_JSON", error_category=ErrorCategory.BUSINESS.value, @@ -92,7 +93,7 @@ async def _invoke_async( response.error_category.value if response.error_category is not None else "" ) - return connector_pb2.InvokeResponse( # type: ignore[name-defined] + return connector_pb2.InvokeResponse( # type: ignore[name-defined, attr-defined] success=response.success, data_json=data_json, error_code=response.error_code or "", diff --git a/src/bindings/mcp_server/auth.py b/src/bindings/mcp_server/auth.py index b2b7bb8..5f22dcc 100644 --- a/src/bindings/mcp_server/auth.py +++ b/src/bindings/mcp_server/auth.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from __future__ import annotations import os diff --git a/src/bindings/rest_api/app.py b/src/bindings/rest_api/app.py index e126d1f..6fee523 100644 --- a/src/bindings/rest_api/app.py +++ b/src/bindings/rest_api/app.py @@ -40,16 +40,56 @@ ) from bindings.rest_api.body_limit import MaxBodySizeMiddleware -# Add project root to sys.path to allow importing from 'playground' package -PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent -if str(PROJECT_ROOT) not in sys.path: - sys.path.append(str(PROJECT_ROOT)) +logger = logging.getLogger("bindings.rest_api") +tracer = trace.get_tracer("bindings.rest_api") -from playground.scenarios import router as scenarios_router # noqa: E402 +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent -logger = logging.getLogger("bindings.rest_api") -tracer = trace.get_tracer("bindings.rest_api") +def _truthy_env(value: str | None) -> bool: + if value is None: + return False + return value.strip().lower() in ("1", "true", "yes", "on") + + +def _playground_enabled() -> bool: + """Return True when demo playground routes/static files should be mounted.""" + raw = os.environ.get("NW_REST_PLAYGROUND_ENABLED") + if raw is not None and raw.strip(): + return _truthy_env(raw) + return (_REPO_ROOT / "playground").is_dir() + + +def _mount_playground(app: FastAPI) -> None: + """Attach scenario API routes and static UI when playground is available.""" + if not _playground_enabled(): + logger.info( + "REST playground disabled", + extra={"reason": "NW_REST_PLAYGROUND_ENABLED=false or playground/ missing"}, + ) + return + + playground_dir = _REPO_ROOT / "playground" + if str(_REPO_ROOT) not in sys.path: + sys.path.append(str(_REPO_ROOT)) + + try: + from playground.scenarios import router as scenarios_router # noqa: E402 + except ImportError as exc: + logger.warning( + "Playground directory present but scenarios module could not be imported; skipping", + extra={"error": str(exc)}, + ) + return + + app.include_router(scenarios_router) + app.mount( + "/playground", + StaticFiles(directory=str(playground_dir), html=True), + name="playground", + ) + logger.info("REST playground mounted at /playground") + app = FastAPI(title="Node Wire - REST API") FastAPIInstrumentor.instrument_app(app) @@ -58,13 +98,7 @@ app.add_middleware(RestAuthMiddleware) app.add_middleware(MaxBodySizeMiddleware, max_body_bytes=_max_body_bytes) -# Include the professional scenarios orchestrator -app.include_router(scenarios_router) - -# Serve the playground UI - use absolute path -BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent -DEMO_DIR = BASE_DIR / "playground" -app.mount("/playground", StaticFiles(directory=str(DEMO_DIR), html=True), name="playground") +_mount_playground(app) _factory: ConnectorFactory | None = None _rate_limiter: InMemoryRateLimiter | None = None diff --git a/src/bindings/rest_api/rate_limit.py b/src/bindings/rest_api/rate_limit.py index 298fc1e..c9bb58a 100644 --- a/src/bindings/rest_api/rate_limit.py +++ b/src/bindings/rest_api/rate_limit.py @@ -1,99 +1,103 @@ -from __future__ import annotations - -import math -import threading -from collections import OrderedDict, deque -from dataclasses import dataclass, field -from time import monotonic - - -@dataclass(frozen=True) -class RateLimitResult: - allowed: bool - retry_after_seconds: int = 0 - - -@dataclass -class _Bucket: - timestamps: deque[float] = field(default_factory=deque) - last_seen: float = 0.0 - - -class InMemoryRateLimiter: - """ - Sliding-window in-memory limiter. - - This is intentionally simple for single-process REST deployments. - Keys are bounded via LRU eviction and idle TTL to prevent unbounded memory growth. - """ - - def __init__( - self, - *, - max_requests: int, - window_seconds: int, - max_tracked_keys: int = 10_000, - key_ttl_seconds: int = 3600, - ) -> None: - if max_requests <= 0: - raise ValueError("max_requests must be > 0") - if window_seconds <= 0: - raise ValueError("window_seconds must be > 0") - if max_tracked_keys <= 0: - raise ValueError("max_tracked_keys must be > 0") - if key_ttl_seconds <= 0: - raise ValueError("key_ttl_seconds must be > 0") - self._max_requests = max_requests - self._window_seconds = float(window_seconds) - self._max_tracked_keys = max_tracked_keys - self._key_ttl_seconds = float(key_ttl_seconds) - self._buckets: OrderedDict[str, _Bucket] = OrderedDict() - self._lock = threading.Lock() - - @property - def tracked_key_count(self) -> int: - with self._lock: - return len(self._buckets) - - def _prune_window(self, bucket: _Bucket, cutoff: float) -> None: - while bucket.timestamps and bucket.timestamps[0] <= cutoff: - bucket.timestamps.popleft() - - def _evict_idle_keys(self, now: float) -> None: - idle_cutoff = now - self._key_ttl_seconds - stale_keys = [ - key for key, bucket in self._buckets.items() if bucket.last_seen <= idle_cutoff - ] - for key in stale_keys: - del self._buckets[key] - - def _evict_lru_keys(self) -> None: - while len(self._buckets) > self._max_tracked_keys: - self._buckets.popitem(last=False) - - def consume(self, key: str) -> RateLimitResult: - now = monotonic() - cutoff = now - self._window_seconds - with self._lock: - self._evict_idle_keys(now) - - bucket = self._buckets.get(key) - if bucket is None: - bucket = _Bucket(last_seen=now) - self._buckets[key] = bucket - else: - bucket.last_seen = now - self._buckets.move_to_end(key) - - self._prune_window(bucket, cutoff) - - if len(bucket.timestamps) >= self._max_requests: - retry_after = max( - 1, - int(math.ceil((bucket.timestamps[0] + self._window_seconds) - now)), - ) - return RateLimitResult(allowed=False, retry_after_seconds=retry_after) - - bucket.timestamps.append(now) - self._evict_lru_keys() - return RateLimitResult(allowed=True) +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +import math +import threading +from collections import OrderedDict, deque +from dataclasses import dataclass, field +from time import monotonic + + +@dataclass(frozen=True) +class RateLimitResult: + allowed: bool + retry_after_seconds: int = 0 + + +@dataclass +class _Bucket: + timestamps: deque[float] = field(default_factory=deque) + last_seen: float = 0.0 + + +class InMemoryRateLimiter: + """ + Sliding-window in-memory limiter. + + This is intentionally simple for single-process REST deployments. + Keys are bounded via LRU eviction and idle TTL to prevent unbounded memory growth. + """ + + def __init__( + self, + *, + max_requests: int, + window_seconds: int, + max_tracked_keys: int = 10_000, + key_ttl_seconds: int = 3600, + ) -> None: + if max_requests <= 0: + raise ValueError("max_requests must be > 0") + if window_seconds <= 0: + raise ValueError("window_seconds must be > 0") + if max_tracked_keys <= 0: + raise ValueError("max_tracked_keys must be > 0") + if key_ttl_seconds <= 0: + raise ValueError("key_ttl_seconds must be > 0") + self._max_requests = max_requests + self._window_seconds = float(window_seconds) + self._max_tracked_keys = max_tracked_keys + self._key_ttl_seconds = float(key_ttl_seconds) + self._buckets: OrderedDict[str, _Bucket] = OrderedDict() + self._lock = threading.Lock() + + @property + def tracked_key_count(self) -> int: + with self._lock: + return len(self._buckets) + + def _prune_window(self, bucket: _Bucket, cutoff: float) -> None: + while bucket.timestamps and bucket.timestamps[0] <= cutoff: + bucket.timestamps.popleft() + + def _evict_idle_keys(self, now: float) -> None: + idle_cutoff = now - self._key_ttl_seconds + stale_keys = [ + key for key, bucket in self._buckets.items() if bucket.last_seen <= idle_cutoff + ] + for key in stale_keys: + del self._buckets[key] + + def _evict_lru_keys(self) -> None: + while len(self._buckets) > self._max_tracked_keys: + self._buckets.popitem(last=False) + + def consume(self, key: str) -> RateLimitResult: + now = monotonic() + cutoff = now - self._window_seconds + with self._lock: + self._evict_idle_keys(now) + + bucket = self._buckets.get(key) + if bucket is None: + bucket = _Bucket(last_seen=now) + self._buckets[key] = bucket + else: + bucket.last_seen = now + self._buckets.move_to_end(key) + + self._prune_window(bucket, cutoff) + + if len(bucket.timestamps) >= self._max_requests: + retry_after = max( + 1, + int(math.ceil((bucket.timestamps[0] + self._window_seconds) - now)), + ) + return RateLimitResult(allowed=False, retry_after_seconds=retry_after) + + bucket.timestamps.append(now) + self._evict_lru_keys() + return RateLimitResult(allowed=True) diff --git a/src/bindings_entrypoint.py b/src/bindings_entrypoint.py index 77b5a65..ba66be5 100644 --- a/src/bindings_entrypoint.py +++ b/src/bindings_entrypoint.py @@ -35,7 +35,8 @@ def main() -> None: if mode == "API": port = int(os.getenv("PORT", "8000")) - uvicorn.run(rest_app, host="0.0.0.0", port=port) + host = os.getenv("NW_REST_HOST", "127.0.0.1") + uvicorn.run(rest_app, host=host, port=port) elif mode == "GRPC": # Import gRPC server lazily so API/MCP modes do not require # gRPC-specific dependencies or generated stubs at import time. diff --git a/src/node_wire_google_drive/__init__.py b/src/node_wire_google_drive/__init__.py index 9348710..162a37e 100644 --- a/src/node_wire_google_drive/__init__.py +++ b/src/node_wire_google_drive/__init__.py @@ -1,6 +1,6 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# - -# Connector subpackage: google_drive +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# + +# Connector subpackage: google_drive diff --git a/src/node_wire_google_drive/exceptions.py b/src/node_wire_google_drive/exceptions.py index d56de6d..dc37724 100644 --- a/src/node_wire_google_drive/exceptions.py +++ b/src/node_wire_google_drive/exceptions.py @@ -1,35 +1,35 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -from __future__ import annotations - - -class GoogleDriveBaseError(Exception): - """Base exception for Google Drive connector errors.""" - - pass - - -class GoogleDriveAuthError(GoogleDriveBaseError): - """Authentication or permissions failure.""" - - pass - - -class GoogleDriveRateLimitError(GoogleDriveBaseError): - """Quota or rate limit exceeded.""" - - pass - - -class GoogleDriveBusinessError(GoogleDriveBaseError): - """Business logic failure (e.g. validation, conflict).""" - - pass - - -class GoogleDriveFatalError(GoogleDriveBaseError): - """Unhandled or unexpected error.""" - - pass +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + + +class GoogleDriveBaseError(Exception): + """Base exception for Google Drive connector errors.""" + + pass + + +class GoogleDriveAuthError(GoogleDriveBaseError): + """Authentication or permissions failure.""" + + pass + + +class GoogleDriveRateLimitError(GoogleDriveBaseError): + """Quota or rate limit exceeded.""" + + pass + + +class GoogleDriveBusinessError(GoogleDriveBaseError): + """Business logic failure (e.g. validation, conflict).""" + + pass + + +class GoogleDriveFatalError(GoogleDriveBaseError): + """Unhandled or unexpected error.""" + + pass diff --git a/src/node_wire_google_drive/schema.py b/src/node_wire_google_drive/schema.py index 8f3f5d2..8432c0e 100644 --- a/src/node_wire_google_drive/schema.py +++ b/src/node_wire_google_drive/schema.py @@ -1,144 +1,144 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -from __future__ import annotations - -from typing import Annotated, Any, Dict, Literal, Optional, Union - -from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator - - -class BaseDriveOperation(BaseModel): - """Base config to strictly forbid unexpected payload fields.""" - - model_config = ConfigDict(extra="forbid") - - -class FilesCreateOperation(BaseDriveOperation): - action: Literal["files.create"] - name: str = Field(..., description="The name of the file.") - mime_type: Optional[str] = Field(None, description="The MIME type of the file.") - parents: Optional[list[str]] = Field(None, description="List of parent folder IDs.") - - -class FilesListOperation(BaseDriveOperation): - action: Literal["files.list"] - page_size: Optional[int] = Field( - 10, ge=1, le=100, description="Do not send null; omit if unsure." - ) - - @field_validator("page_size", mode="before") - @classmethod - def _default_page_size(cls, v: Any) -> int: - return 10 if v is None else int(v) - - query: Optional[str] = Field(None, description="Search query string.") - fields: Optional[str] = Field( - None, - description=( - "Optional fields mask for the list response. If omitted, the connector " - "uses a performant default: nextPageToken, files(id, name, mimeType, webViewLink)." - ), - ) - page_token: Optional[str] = Field( - None, - description="Token for the next page of results from a previous files.list response.", - ) - - -class PermissionsCreateOperation(BaseDriveOperation): - action: Literal["permissions.create"] - file_id: str - role: Literal["reader", "commenter", "writer", "owner"] - email_address: Optional[str] = None - type: Literal["user", "group", "domain", "anyone"] - domain: Optional[str] = Field(None, description="G Suite domain when type is domain.") - - @field_validator("email_address", "domain", mode="before") - @classmethod - def _empty_str_to_none(cls, v: Any) -> Any: - if isinstance(v, str) and not v.strip(): - return None - return v - - @model_validator(mode="after") - def require_fields_for_perm_type(self) -> "PermissionsCreateOperation": - if self.type in ("user", "group"): - if not (self.email_address or "").strip(): - raise ValueError("email_address is required for user and group permission types") - elif self.type == "domain": - if not (self.domain or "").strip(): - raise ValueError("domain is required for domain permission type") - return self - - -class FilesGetOperation(BaseDriveOperation): - action: Literal["files.get"] - file_id: str - fields: Optional[str] = Field( - None, - description=("Optional fields mask; if omitted, a safe default is used by the connector."), - ) - - -class FilesUpdateOperation(BaseDriveOperation): - action: Literal["files.update"] - file_id: str - name: Optional[str] = Field(None, description="New file name.") - mime_type: Optional[str] = Field(None, description="New MIME type.") - add_parents: Optional[list[str]] = Field( - None, description="Parent IDs to add (see Drive API addParents)." - ) - remove_parents: Optional[list[str]] = Field( - None, description="Parent IDs to remove (see Drive API removeParents)." - ) - - -class FilesUploadOperation(BaseDriveOperation): - action: Literal["files.upload"] - name: str = Field(..., description="The name of the file.") - mime_type: str = Field(..., description="The MIME type of the file content.") - parents: Optional[list[str]] = Field(None, description="List of parent folder IDs.") - content: Optional[str] = Field(None, description="UTF-8 text content to upload.") - content_base64: Optional[str] = Field( - None, description="Base64 encoded binary content to upload." - ) - - @model_validator(mode="after") - def exactly_one_of_content_or_base64(self) -> "FilesUploadOperation": - """Match Drive upload semantics: exactly one body source (aligned with action_spec).""" - has_text = self.content is not None - has_b64 = self.content_base64 is not None - if not has_text and not has_b64: - raise ValueError("Provide exactly one of 'content' or 'content_base64'.") - if has_text and has_b64: - raise ValueError("Provide exactly one of 'content' or 'content_base64', not both.") - return self - - -class FilesDeleteOperation(BaseDriveOperation): - action: Literal["files.delete"] - file_id: str - - -_GoogleDriveOperationUnion = Annotated[ - Union[ - FilesCreateOperation, - FilesListOperation, - PermissionsCreateOperation, - FilesGetOperation, - FilesUpdateOperation, - FilesUploadOperation, - FilesDeleteOperation, - ], - Field(discriminator="action"), -] - -# Discriminated union for tests/agents; must stay aligned with GoogleDriveConnector @nw_action set. -GoogleDriveOperationInput = RootModel[_GoogleDriveOperationUnion] - - -class GoogleDriveOperationOutput(BaseModel): - raw: Dict[str, Any] - description: str +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +from typing import Annotated, Any, Dict, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator + + +class BaseDriveOperation(BaseModel): + """Base config to strictly forbid unexpected payload fields.""" + + model_config = ConfigDict(extra="forbid") + + +class FilesCreateOperation(BaseDriveOperation): + action: Literal["files.create"] + name: str = Field(..., description="The name of the file.") + mime_type: Optional[str] = Field(None, description="The MIME type of the file.") + parents: Optional[list[str]] = Field(None, description="List of parent folder IDs.") + + +class FilesListOperation(BaseDriveOperation): + action: Literal["files.list"] + page_size: Optional[int] = Field( + 10, ge=1, le=100, description="Do not send null; omit if unsure." + ) + + @field_validator("page_size", mode="before") + @classmethod + def _default_page_size(cls, v: Any) -> int: + return 10 if v is None else int(v) + + query: Optional[str] = Field(None, description="Search query string.") + fields: Optional[str] = Field( + None, + description=( + "Optional fields mask for the list response. If omitted, the connector " + "uses a performant default: nextPageToken, files(id, name, mimeType, webViewLink)." + ), + ) + page_token: Optional[str] = Field( + None, + description="Token for the next page of results from a previous files.list response.", + ) + + +class PermissionsCreateOperation(BaseDriveOperation): + action: Literal["permissions.create"] + file_id: str + role: Literal["reader", "commenter", "writer", "owner"] + email_address: Optional[str] = None + type: Literal["user", "group", "domain", "anyone"] + domain: Optional[str] = Field(None, description="G Suite domain when type is domain.") + + @field_validator("email_address", "domain", mode="before") + @classmethod + def _empty_str_to_none(cls, v: Any) -> Any: + if isinstance(v, str) and not v.strip(): + return None + return v + + @model_validator(mode="after") + def require_fields_for_perm_type(self) -> "PermissionsCreateOperation": + if self.type in ("user", "group"): + if not (self.email_address or "").strip(): + raise ValueError("email_address is required for user and group permission types") + elif self.type == "domain": + if not (self.domain or "").strip(): + raise ValueError("domain is required for domain permission type") + return self + + +class FilesGetOperation(BaseDriveOperation): + action: Literal["files.get"] + file_id: str + fields: Optional[str] = Field( + None, + description=("Optional fields mask; if omitted, a safe default is used by the connector."), + ) + + +class FilesUpdateOperation(BaseDriveOperation): + action: Literal["files.update"] + file_id: str + name: Optional[str] = Field(None, description="New file name.") + mime_type: Optional[str] = Field(None, description="New MIME type.") + add_parents: Optional[list[str]] = Field( + None, description="Parent IDs to add (see Drive API addParents)." + ) + remove_parents: Optional[list[str]] = Field( + None, description="Parent IDs to remove (see Drive API removeParents)." + ) + + +class FilesUploadOperation(BaseDriveOperation): + action: Literal["files.upload"] + name: str = Field(..., description="The name of the file.") + mime_type: str = Field(..., description="The MIME type of the file content.") + parents: Optional[list[str]] = Field(None, description="List of parent folder IDs.") + content: Optional[str] = Field(None, description="UTF-8 text content to upload.") + content_base64: Optional[str] = Field( + None, description="Base64 encoded binary content to upload." + ) + + @model_validator(mode="after") + def exactly_one_of_content_or_base64(self) -> "FilesUploadOperation": + """Match Drive upload semantics: exactly one body source (aligned with action_spec).""" + has_text = self.content is not None + has_b64 = self.content_base64 is not None + if not has_text and not has_b64: + raise ValueError("Provide exactly one of 'content' or 'content_base64'.") + if has_text and has_b64: + raise ValueError("Provide exactly one of 'content' or 'content_base64', not both.") + return self + + +class FilesDeleteOperation(BaseDriveOperation): + action: Literal["files.delete"] + file_id: str + + +_GoogleDriveOperationUnion = Annotated[ + Union[ + FilesCreateOperation, + FilesListOperation, + PermissionsCreateOperation, + FilesGetOperation, + FilesUpdateOperation, + FilesUploadOperation, + FilesDeleteOperation, + ], + Field(discriminator="action"), +] + +# Discriminated union for tests/agents; must stay aligned with GoogleDriveConnector @nw_action set. +GoogleDriveOperationInput = RootModel[_GoogleDriveOperationUnion] + + +class GoogleDriveOperationOutput(BaseModel): + raw: Dict[str, Any] + description: str diff --git a/src/node_wire_http_generic/__init__.py b/src/node_wire_http_generic/__init__.py index a76ac90..ab2cae0 100644 --- a/src/node_wire_http_generic/__init__.py +++ b/src/node_wire_http_generic/__init__.py @@ -1,6 +1,6 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# - -# Connector subpackage: http_generic +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# + +# Connector subpackage: http_generic diff --git a/src/node_wire_http_generic/schema.py b/src/node_wire_http_generic/schema.py index b91e920..f895b46 100644 --- a/src/node_wire_http_generic/schema.py +++ b/src/node_wire_http_generic/schema.py @@ -1,103 +1,103 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -from __future__ import annotations - -import ipaddress -import os -import re -from typing import Any, Dict, Literal, Optional -from urllib.parse import urlsplit - -from pydantic import BaseModel, HttpUrl, field_validator - -_ALLOWED_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"} -_BLOCKED_HOSTNAMES = { - "localhost", - "metadata.google.internal", - "metadata", -} - -# Optional egress allowlist (preferred control). Comma/space separated hostnames. -_ALLOWED_HOSTS_ENV = "NW_HTTP_GENERIC_ALLOWED_HOSTS" - - -def load_allowed_hosts() -> frozenset[str]: - """Return the configured egress allowlist of permitted destination hosts. - - Empty (unset) means "no allowlist configured" — the connector then falls - back to the denylist + resolved-IP range checks. When set, only the listed - hostnames may be reached. - """ - raw = os.environ.get(_ALLOWED_HOSTS_ENV) - if not raw or not raw.strip(): - return frozenset() - return frozenset(h.strip().lower().rstrip(".") for h in re.split(r"[\s,]+", raw) if h.strip()) - - -class HttpRequestInput(BaseModel): - action: Literal["request"] = "request" - url: HttpUrl - method: str - headers: Optional[Dict[str, str]] = None - params: Optional[Dict[str, str]] = None - body: Optional[Any] = None - - @field_validator("method", mode="before") - @classmethod - def normalize_and_validate_method(cls, value: Any) -> Any: - if not isinstance(value, str): - raise ValueError("method must be a string") - normalized = value.strip().upper() - if normalized not in _ALLOWED_METHODS: - raise ValueError(f"method must be one of: {', '.join(sorted(_ALLOWED_METHODS))}") - return normalized - - @field_validator("url") - @classmethod - def block_internal_targets(cls, value: HttpUrl) -> HttpUrl: - parts = urlsplit(str(value)) - host = (parts.hostname or "").strip().lower().rstrip(".") - if host in _BLOCKED_HOSTNAMES: - raise ValueError("url host is blocked by outbound security policy") - if _is_blocked_ip_literal(host): - raise ValueError("url host resolves to a blocked network target") - return value - - -def is_blocked_ip(ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: - """Return True if an already-parsed address belongs to a blocked range. - - Shared by the schema-time literal check and the connection-time resolved-IP - check in :mod:`node_wire_http_generic.logic`, so both apply identical policy. - """ - # Normalize IPv4-mapped IPv6 (``::ffff:127.0.0.1``) to its IPv4 form so the - # range checks below catch loopback/private targets smuggled through IPv6. - if isinstance(ip_obj, ipaddress.IPv6Address) and ip_obj.ipv4_mapped is not None: - ip_obj = ip_obj.ipv4_mapped - if ip_obj.is_loopback or ip_obj.is_private or ip_obj.is_link_local: - return True - if ip_obj.is_multicast or ip_obj.is_reserved or ip_obj.is_unspecified: - return True - # Explicit cloud metadata target (IMDS). - if str(ip_obj) in ("169.254.169.254", "fd00:ec2::254"): - return True - return False - - -def _is_blocked_ip_literal(host: str) -> bool: - # Reject non-dotted-decimal numeric hosts (decimal ``2130706433``, octal - # ``0177.0.0.1``) that ``ipaddress`` rejects but the OS socket layer accepts. - # We only treat a host as an IP literal when it parses in canonical form. - try: - ip_obj = ipaddress.ip_address(host) - except ValueError: - return False - return is_blocked_ip(ip_obj) - - -class HttpResponseOutput(BaseModel): - status_code: int - headers: Dict[str, str] - body: Any +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +import ipaddress +import os +import re +from typing import Any, Dict, Literal, Optional +from urllib.parse import urlsplit + +from pydantic import BaseModel, HttpUrl, field_validator + +_ALLOWED_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"} +_BLOCKED_HOSTNAMES = { + "localhost", + "metadata.google.internal", + "metadata", +} + +# Optional egress allowlist (preferred control). Comma/space separated hostnames. +_ALLOWED_HOSTS_ENV = "NW_HTTP_GENERIC_ALLOWED_HOSTS" + + +def load_allowed_hosts() -> frozenset[str]: + """Return the configured egress allowlist of permitted destination hosts. + + Empty (unset) means "no allowlist configured" — the connector then falls + back to the denylist + resolved-IP range checks. When set, only the listed + hostnames may be reached. + """ + raw = os.environ.get(_ALLOWED_HOSTS_ENV) + if not raw or not raw.strip(): + return frozenset() + return frozenset(h.strip().lower().rstrip(".") for h in re.split(r"[\s,]+", raw) if h.strip()) + + +class HttpRequestInput(BaseModel): + action: Literal["request"] = "request" + url: HttpUrl + method: str + headers: Optional[Dict[str, str]] = None + params: Optional[Dict[str, str]] = None + body: Optional[Any] = None + + @field_validator("method", mode="before") + @classmethod + def normalize_and_validate_method(cls, value: Any) -> Any: + if not isinstance(value, str): + raise ValueError("method must be a string") + normalized = value.strip().upper() + if normalized not in _ALLOWED_METHODS: + raise ValueError(f"method must be one of: {', '.join(sorted(_ALLOWED_METHODS))}") + return normalized + + @field_validator("url") + @classmethod + def block_internal_targets(cls, value: HttpUrl) -> HttpUrl: + parts = urlsplit(str(value)) + host = (parts.hostname or "").strip().lower().rstrip(".") + if host in _BLOCKED_HOSTNAMES: + raise ValueError("url host is blocked by outbound security policy") + if _is_blocked_ip_literal(host): + raise ValueError("url host resolves to a blocked network target") + return value + + +def is_blocked_ip(ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + """Return True if an already-parsed address belongs to a blocked range. + + Shared by the schema-time literal check and the connection-time resolved-IP + check in :mod:`node_wire_http_generic.logic`, so both apply identical policy. + """ + # Normalize IPv4-mapped IPv6 (``::ffff:127.0.0.1``) to its IPv4 form so the + # range checks below catch loopback/private targets smuggled through IPv6. + if isinstance(ip_obj, ipaddress.IPv6Address) and ip_obj.ipv4_mapped is not None: + ip_obj = ip_obj.ipv4_mapped + if ip_obj.is_loopback or ip_obj.is_private or ip_obj.is_link_local: + return True + if ip_obj.is_multicast or ip_obj.is_reserved or ip_obj.is_unspecified: + return True + # Explicit cloud metadata target (IMDS). + if str(ip_obj) in ("169.254.169.254", "fd00:ec2::254"): + return True + return False + + +def _is_blocked_ip_literal(host: str) -> bool: + # Reject non-dotted-decimal numeric hosts (decimal ``2130706433``, octal + # ``0177.0.0.1``) that ``ipaddress`` rejects but the OS socket layer accepts. + # We only treat a host as an IP literal when it parses in canonical form. + try: + ip_obj = ipaddress.ip_address(host) + except ValueError: + return False + return is_blocked_ip(ip_obj) + + +class HttpResponseOutput(BaseModel): + status_code: int + headers: Dict[str, str] + body: Any diff --git a/src/node_wire_runtime/auth/__init__.py b/src/node_wire_runtime/auth/__init__.py index 71ce353..025ee21 100644 --- a/src/node_wire_runtime/auth/__init__.py +++ b/src/node_wire_runtime/auth/__init__.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """ node_wire_runtime.auth ======================= diff --git a/src/node_wire_runtime/auth/base.py b/src/node_wire_runtime/auth/base.py index 0792341..45eb281 100644 --- a/src/node_wire_runtime/auth/base.py +++ b/src/node_wire_runtime/auth/base.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """ node_wire_runtime.auth.base ============================ diff --git a/src/node_wire_runtime/auth/no_auth.py b/src/node_wire_runtime/auth/no_auth.py index c0bd35a..b52971e 100644 --- a/src/node_wire_runtime/auth/no_auth.py +++ b/src/node_wire_runtime/auth/no_auth.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """ node_wire_runtime.auth.no_auth ================================ diff --git a/src/node_wire_runtime/auth/oauth2.py b/src/node_wire_runtime/auth/oauth2.py index ef4c55d..103aeb6 100644 --- a/src/node_wire_runtime/auth/oauth2.py +++ b/src/node_wire_runtime/auth/oauth2.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """ node_wire_runtime.auth.oauth2 ================================ diff --git a/src/node_wire_runtime/auth/service_account.py b/src/node_wire_runtime/auth/service_account.py index fde0bd2..b7e5db6 100644 --- a/src/node_wire_runtime/auth/service_account.py +++ b/src/node_wire_runtime/auth/service_account.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """ node_wire_runtime.auth.service_account ========================================= diff --git a/src/node_wire_runtime/auth/static_token.py b/src/node_wire_runtime/auth/static_token.py index 1b30fd1..87ae1fd 100644 --- a/src/node_wire_runtime/auth/static_token.py +++ b/src/node_wire_runtime/auth/static_token.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """ node_wire_runtime.auth.static_token ====================================== diff --git a/src/node_wire_runtime/caller_identity.py b/src/node_wire_runtime/caller_identity.py index 5511efb..2c38a45 100644 --- a/src/node_wire_runtime/caller_identity.py +++ b/src/node_wire_runtime/caller_identity.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """Transport-neutral caller identity for connector execution and policy hooks.""" from __future__ import annotations diff --git a/src/node_wire_runtime/errors.py b/src/node_wire_runtime/errors.py index 56b01f7..2a9a155 100644 --- a/src/node_wire_runtime/errors.py +++ b/src/node_wire_runtime/errors.py @@ -1,47 +1,47 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -from __future__ import annotations - -from dataclasses import dataclass -from typing import Dict, Optional, Type - -from .models import ErrorCategory - - -@dataclass -class MappedError: - code: str - category: ErrorCategory - - -class ErrorMapper: - """ - Global registry mapping exception classes to a standardized error taxonomy. - - Connector-specific registration is performed in Layer B (registration modules). - """ - - _registry: Dict[Type[BaseException], MappedError] = {} - - @classmethod - def register( - cls, exc_type: Type[BaseException], category: ErrorCategory, code: Optional[str] = None - ) -> None: - """ - Register an exception type with a category and optional stable error code. - """ - error_code = code or exc_type.__name__ - cls._registry[exc_type] = MappedError(code=error_code, category=category) - - @classmethod - def resolve(cls, exc: BaseException) -> MappedError: - """ - Resolve an exception instance to a mapped error. - Defaults to FATAL when no explicit mapping exists. - """ - for exc_type, mapped in cls._registry.items(): - if isinstance(exc, exc_type): - return mapped - return MappedError(code=type(exc).__name__, category=ErrorCategory.FATAL) +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Optional, Type + +from .models import ErrorCategory + + +@dataclass +class MappedError: + code: str + category: ErrorCategory + + +class ErrorMapper: + """ + Global registry mapping exception classes to a standardized error taxonomy. + + Connector-specific registration is performed in Layer B (registration modules). + """ + + _registry: Dict[Type[BaseException], MappedError] = {} + + @classmethod + def register( + cls, exc_type: Type[BaseException], category: ErrorCategory, code: Optional[str] = None + ) -> None: + """ + Register an exception type with a category and optional stable error code. + """ + error_code = code or exc_type.__name__ + cls._registry[exc_type] = MappedError(code=error_code, category=category) + + @classmethod + def resolve(cls, exc: BaseException) -> MappedError: + """ + Resolve an exception instance to a mapped error. + Defaults to FATAL when no explicit mapping exists. + """ + for exc_type, mapped in cls._registry.items(): + if isinstance(exc, exc_type): + return mapped + return MappedError(code=type(exc).__name__, category=ErrorCategory.FATAL) diff --git a/src/node_wire_runtime/log_sanitization.py b/src/node_wire_runtime/log_sanitization.py index 608aae3..60f0258 100644 --- a/src/node_wire_runtime/log_sanitization.py +++ b/src/node_wire_runtime/log_sanitization.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """Value-aware log redaction for PHI and secrets across all handlers and OTLP export.""" from __future__ import annotations diff --git a/src/node_wire_runtime/mcp_client/__init__.py b/src/node_wire_runtime/mcp_client/__init__.py index 0e38c65..ff97e22 100644 --- a/src/node_wire_runtime/mcp_client/__init__.py +++ b/src/node_wire_runtime/mcp_client/__init__.py @@ -1,135 +1,135 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -"""Outbound MCP OAuth 2.1 client (MCP Authorization spec 2025-11-25).""" - -from .challenges import WwwAuthenticateChallenge, parse_www_authenticate -from .client import McpOAuthClient, create_http_mcp_client, create_http_mcp_clients_for_urls -from .config import ( - AuthConfig, - AuthClientConfig, - AuthDcrConfig, - AuthDiscoveryConfig, - AuthRedirectConfig, - AuthTokenConfig, - McpClientConfig, - McpServerConfig, - RedirectMode, - TokenStoreMode, - canonicalize_mcp_server_url, -) -from .discovery import ( - AuthorizationServerMetadata, - DiscoveryCache, - DiscoveryResult, - ProtectedResourceMetadata, - discover, - discovery_cache_for_config, - fetch_authorization_server_metadata, - fetch_protected_resource_metadata, - parse_resource_metadata_url, -) -from .dcr import register_dynamic_client, resolve_client_registration -from .env_config import ( - config_from_env, - legacy_static_mcp_token, - mcp_oauth_enabled, - mcp_oauth_user_id, -) -from .exceptions import ( - McpAudienceMismatch, - McpOAuthConfigurationError, - McpOAuthDiscoveryError, - McpOAuthError, - McpOAuthFlowAborted, - McpOAuthRegistrationError, - McpOAuthSecurityError, - McpTokenRefreshError, -) -from .oauth_flow import ( - AuthorizationCodeFlow, - AuthorizationSession, - OAuthTokenSet, - PkcePair, - build_authorization_url, - generate_pkce_pair, - generate_state, -) -from .redirect_listener import ( - AuthorizationCallback, - LoopbackRedirectBinding, - LoopbackRedirectListener, -) -from .storage import ClientRegistration, RegistrationStore, default_registration_store_dir -from .token_manager import TokenManager -from .token_storage import ( - InMemoryTokenStore, - StoredOAuthTokens, - TokenStore, - make_token_store, - stored_from_oauth_response, - token_partition_key, -) - -__all__ = [ - "WwwAuthenticateChallenge", - "parse_www_authenticate", - "McpOAuthClient", - "create_http_mcp_client", - "create_http_mcp_clients_for_urls", - "AuthConfig", - "AuthClientConfig", - "AuthDcrConfig", - "AuthDiscoveryConfig", - "AuthRedirectConfig", - "AuthTokenConfig", - "McpClientConfig", - "McpServerConfig", - "RedirectMode", - "TokenStoreMode", - "canonicalize_mcp_server_url", - "AuthorizationServerMetadata", - "DiscoveryCache", - "DiscoveryResult", - "ProtectedResourceMetadata", - "discover", - "discovery_cache_for_config", - "fetch_authorization_server_metadata", - "fetch_protected_resource_metadata", - "parse_resource_metadata_url", - "register_dynamic_client", - "resolve_client_registration", - "config_from_env", - "legacy_static_mcp_token", - "mcp_oauth_enabled", - "mcp_oauth_user_id", - "McpAudienceMismatch", - "McpOAuthConfigurationError", - "McpOAuthDiscoveryError", - "McpOAuthError", - "McpOAuthFlowAborted", - "McpOAuthRegistrationError", - "McpOAuthSecurityError", - "McpTokenRefreshError", - "AuthorizationCodeFlow", - "AuthorizationSession", - "OAuthTokenSet", - "PkcePair", - "build_authorization_url", - "generate_pkce_pair", - "generate_state", - "AuthorizationCallback", - "LoopbackRedirectBinding", - "LoopbackRedirectListener", - "ClientRegistration", - "RegistrationStore", - "default_registration_store_dir", - "TokenManager", - "InMemoryTokenStore", - "StoredOAuthTokens", - "TokenStore", - "make_token_store", - "stored_from_oauth_response", - "token_partition_key", -] +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +"""Outbound MCP OAuth 2.1 client (MCP Authorization spec 2025-11-25).""" + +from .challenges import WwwAuthenticateChallenge, parse_www_authenticate +from .client import McpOAuthClient, create_http_mcp_client, create_http_mcp_clients_for_urls +from .config import ( + AuthConfig, + AuthClientConfig, + AuthDcrConfig, + AuthDiscoveryConfig, + AuthRedirectConfig, + AuthTokenConfig, + McpClientConfig, + McpServerConfig, + RedirectMode, + TokenStoreMode, + canonicalize_mcp_server_url, +) +from .discovery import ( + AuthorizationServerMetadata, + DiscoveryCache, + DiscoveryResult, + ProtectedResourceMetadata, + discover, + discovery_cache_for_config, + fetch_authorization_server_metadata, + fetch_protected_resource_metadata, + parse_resource_metadata_url, +) +from .dcr import register_dynamic_client, resolve_client_registration +from .env_config import ( + config_from_env, + legacy_static_mcp_token, + mcp_oauth_enabled, + mcp_oauth_user_id, +) +from .exceptions import ( + McpAudienceMismatch, + McpOAuthConfigurationError, + McpOAuthDiscoveryError, + McpOAuthError, + McpOAuthFlowAborted, + McpOAuthRegistrationError, + McpOAuthSecurityError, + McpTokenRefreshError, +) +from .oauth_flow import ( + AuthorizationCodeFlow, + AuthorizationSession, + OAuthTokenSet, + PkcePair, + build_authorization_url, + generate_pkce_pair, + generate_state, +) +from .redirect_listener import ( + AuthorizationCallback, + LoopbackRedirectBinding, + LoopbackRedirectListener, +) +from .storage import ClientRegistration, RegistrationStore, default_registration_store_dir +from .token_manager import TokenManager +from .token_storage import ( + InMemoryTokenStore, + StoredOAuthTokens, + TokenStore, + make_token_store, + stored_from_oauth_response, + token_partition_key, +) + +__all__ = [ + "WwwAuthenticateChallenge", + "parse_www_authenticate", + "McpOAuthClient", + "create_http_mcp_client", + "create_http_mcp_clients_for_urls", + "AuthConfig", + "AuthClientConfig", + "AuthDcrConfig", + "AuthDiscoveryConfig", + "AuthRedirectConfig", + "AuthTokenConfig", + "McpClientConfig", + "McpServerConfig", + "RedirectMode", + "TokenStoreMode", + "canonicalize_mcp_server_url", + "AuthorizationServerMetadata", + "DiscoveryCache", + "DiscoveryResult", + "ProtectedResourceMetadata", + "discover", + "discovery_cache_for_config", + "fetch_authorization_server_metadata", + "fetch_protected_resource_metadata", + "parse_resource_metadata_url", + "register_dynamic_client", + "resolve_client_registration", + "config_from_env", + "legacy_static_mcp_token", + "mcp_oauth_enabled", + "mcp_oauth_user_id", + "McpAudienceMismatch", + "McpOAuthConfigurationError", + "McpOAuthDiscoveryError", + "McpOAuthError", + "McpOAuthFlowAborted", + "McpOAuthRegistrationError", + "McpOAuthSecurityError", + "McpTokenRefreshError", + "AuthorizationCodeFlow", + "AuthorizationSession", + "OAuthTokenSet", + "PkcePair", + "build_authorization_url", + "generate_pkce_pair", + "generate_state", + "AuthorizationCallback", + "LoopbackRedirectBinding", + "LoopbackRedirectListener", + "ClientRegistration", + "RegistrationStore", + "default_registration_store_dir", + "TokenManager", + "InMemoryTokenStore", + "StoredOAuthTokens", + "TokenStore", + "make_token_store", + "stored_from_oauth_response", + "token_partition_key", +] diff --git a/src/node_wire_runtime/mcp_client/challenges.py b/src/node_wire_runtime/mcp_client/challenges.py index ed8bccb..e745f49 100644 --- a/src/node_wire_runtime/mcp_client/challenges.py +++ b/src/node_wire_runtime/mcp_client/challenges.py @@ -1,56 +1,56 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -"""WWW-Authenticate challenge parsing for MCP OAuth error handling.""" - -from __future__ import annotations - -import re -from dataclasses import dataclass -from typing import Optional - -from .discovery import parse_resource_metadata_url - -_ERROR_RE = re.compile(r'error\s*=\s*"([^"]+)"', re.IGNORECASE) -_SCOPE_RE = re.compile(r'scope\s*=\s*"([^"]+)"', re.IGNORECASE) - - -@dataclass(frozen=True) -class WwwAuthenticateChallenge: - scheme: str - error: Optional[str] - error_description: Optional[str] - resource_metadata: Optional[str] - scope: Optional[str] - raw: str - - @property - def is_invalid_token(self) -> bool: - return (self.error or "").lower() == "invalid_token" - - @property - def is_insufficient_scope(self) -> bool: - return (self.error or "").lower() == "insufficient_scope" - - @property - def treat_as_unauthorized(self) -> bool: - return self.is_invalid_token or self.error is None - - -def parse_www_authenticate(header_value: Optional[str]) -> Optional[WwwAuthenticateChallenge]: - if not header_value or not header_value.strip(): - return None - raw = header_value.strip() - scheme = raw.split(" ", 1)[0] if raw else "Bearer" - error_match = _ERROR_RE.search(raw) - scope_match = _SCOPE_RE.search(raw) - desc_match = re.search(r'error_description\s*=\s*"([^"]+)"', raw, re.IGNORECASE) - return WwwAuthenticateChallenge( - scheme=scheme, - error=error_match.group(1) if error_match else None, - error_description=desc_match.group(1) if desc_match else None, - resource_metadata=parse_resource_metadata_url(raw), - scope=scope_match.group(1) if scope_match else None, - raw=raw, - ) +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +"""WWW-Authenticate challenge parsing for MCP OAuth error handling.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Optional + +from .discovery import parse_resource_metadata_url + +_ERROR_RE = re.compile(r'error\s*=\s*"([^"]+)"', re.IGNORECASE) +_SCOPE_RE = re.compile(r'scope\s*=\s*"([^"]+)"', re.IGNORECASE) + + +@dataclass(frozen=True) +class WwwAuthenticateChallenge: + scheme: str + error: Optional[str] + error_description: Optional[str] + resource_metadata: Optional[str] + scope: Optional[str] + raw: str + + @property + def is_invalid_token(self) -> bool: + return (self.error or "").lower() == "invalid_token" + + @property + def is_insufficient_scope(self) -> bool: + return (self.error or "").lower() == "insufficient_scope" + + @property + def treat_as_unauthorized(self) -> bool: + return self.is_invalid_token or self.error is None + + +def parse_www_authenticate(header_value: Optional[str]) -> Optional[WwwAuthenticateChallenge]: + if not header_value or not header_value.strip(): + return None + raw = header_value.strip() + scheme = raw.split(" ", 1)[0] if raw else "Bearer" + error_match = _ERROR_RE.search(raw) + scope_match = _SCOPE_RE.search(raw) + desc_match = re.search(r'error_description\s*=\s*"([^"]+)"', raw, re.IGNORECASE) + return WwwAuthenticateChallenge( + scheme=scheme, + error=error_match.group(1) if error_match else None, + error_description=desc_match.group(1) if desc_match else None, + resource_metadata=parse_resource_metadata_url(raw), + scope=scope_match.group(1) if scope_match else None, + raw=raw, + ) diff --git a/src/node_wire_runtime/mcp_client/client.py b/src/node_wire_runtime/mcp_client/client.py index fdabf7a..44a88aa 100644 --- a/src/node_wire_runtime/mcp_client/client.py +++ b/src/node_wire_runtime/mcp_client/client.py @@ -1,233 +1,233 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -"""HTTP MCP client with OAuth 2.1 authorization (streamable HTTP transport).""" - -from __future__ import annotations - -import logging -import uuid -from typing import Any, Awaitable, Callable, Dict, List, Optional - -import httpx - -from .config import McpClientConfig -from .env_config import ( - config_from_env, - legacy_static_mcp_token, - mcp_oauth_enabled, - mcp_oauth_user_id, -) -from .exceptions import McpOAuthFlowAborted, McpTokenRefreshError -from .oauth_flow import OAuthTokenSet -from .token_manager import TokenManager -from .token_storage import make_token_store - -logger = logging.getLogger("runtime.mcp_client.client") - - -class McpOAuthClient: - """ - Async MCP client over streamable HTTP with spec-compliant outbound OAuth. - - Implements ``list_tools`` / ``call_tool`` compatible with :class:`agents.toolhive.McpClient`. - """ - - def __init__( - self, - base_url: str, - *, - config: Optional[McpClientConfig] = None, - user_id: Optional[str] = None, - token_manager: Optional[TokenManager] = None, - http_client: Optional[httpx.AsyncClient] = None, - reauthorize: Optional[Callable[[], Awaitable[OAuthTokenSet]]] = None, - ) -> None: - self._base_url = base_url.rstrip("/") - self._config = config or config_from_env(server_url=base_url) - self._user_id = user_id or mcp_oauth_user_id() - self._session_id: Optional[str] = None - self._initialized = False - self._http = http_client - self._owns_http = http_client is None - self._token_manager = token_manager or TokenManager( - self._config, - user_id=self._user_id, - token_store=make_token_store(self._config), - reauthorize=reauthorize, - ) - - async def _get_client(self) -> httpx.AsyncClient: - if self._http is None: - self._http = httpx.AsyncClient(timeout=60.0, verify=True) - return self._http - - async def aclose(self) -> None: - if self._owns_http and self._http is not None: - await self._http.aclose() - self._http = None - - async def ensure_authorized(self, *, www_authenticate: Optional[str] = None) -> None: - """Obtain or refresh tokens; run authorization code flow if needed.""" - http = await self._get_client() - await self._token_manager.ensure_discovery(www_authenticate=www_authenticate) - await self._token_manager.get_bearer_token(http_client=http) - - async def _auth_headers(self) -> Dict[str, str]: - http = await self._get_client() - token = await self._token_manager.get_bearer_token(http_client=http) - return { - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - "Authorization": f"Bearer {token}", - } - - def _merge_headers(self, extra: Dict[str, str]) -> Dict[str, str]: - out = dict(extra) - if self._session_id: - out["Mcp-Session-Id"] = self._session_id - return out - - async def _request( - self, - method: str, - *, - json_body: Optional[Dict[str, Any]] = None, - retried_auth: bool = False, - ) -> httpx.Response: - client = await self._get_client() - headers = self._merge_headers(await self._auth_headers()) - resp = await client.request(method, self._base_url, json=json_body, headers=headers) - - if resp.status_code == 401 and not retried_auth: - www = resp.headers.get("WWW-Authenticate") - action = await self._token_manager.handle_mcp_response(401, www) - if action == "forbidden": - resp.raise_for_status() - if action == "reauthorize": - await self.ensure_authorized(www_authenticate=www) - return await self._request( - method, - json_body=json_body, - retried_auth=True, - ) - stored = self._token_manager.load_stored() - if stored and stored.refresh_token: - try: - await self._token_manager.refresh_tokens(stored, http_client=client) - return await self._request( - method, - json_body=json_body, - retried_auth=True, - ) - except McpTokenRefreshError: - self._token_manager.discard_tokens() - else: - self._token_manager.discard_tokens() - try: - await self._token_manager.get_bearer_token(http_client=client) - except McpOAuthFlowAborted: - await self.ensure_authorized(www_authenticate=www) - return await self._request( - method, - json_body=json_body, - retried_auth=True, - ) - - if resp.status_code == 403: - await self._token_manager.handle_mcp_response(403, None) - resp.raise_for_status() - - return resp - - async def _initialize(self) -> None: - init_payload = { - "jsonrpc": "2.0", - "id": str(uuid.uuid4()), - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "node-wire", "version": "1.0.0"}, - }, - } - resp = await self._request("POST", json_body=init_payload) - resp.raise_for_status() - session_id = resp.headers.get("Mcp-Session-Id") - if session_id: - self._session_id = session_id - data = resp.json() - if "error" in data: - raise RuntimeError(f"MCP initialize error: {data['error']}") - - notif = {"jsonrpc": "2.0", "method": "notifications/initialized"} - try: - await self._request("POST", json_body=notif) - except Exception: - pass - self._initialized = True - - async def _rpc(self, method: str, params: Dict[str, Any]) -> Any: - if not self._initialized: - await self._initialize() - - payload: Dict[str, Any] = { - "jsonrpc": "2.0", - "id": str(uuid.uuid4()), - "method": method, - "params": params, - } - resp = await self._request("POST", json_body=payload) - resp.raise_for_status() - data = resp.json() - if "error" in data: - raise RuntimeError(f"MCP error: {data['error']}") - return data.get("result") - - async def list_tools(self) -> List[Dict[str, Any]]: - result = await self._rpc("tools/list", {}) - return result.get("tools", []) - - async def call_tool(self, name: str, arguments: Dict[str, Any]) -> str: - result = await self._rpc("tools/call", {"name": name, "arguments": arguments}) - content = result.get("content", []) - if isinstance(content, list): - parts = [c.get("text", "") for c in content if c.get("type") == "text"] - return "\n".join(parts) - return str(content) - - -def create_http_mcp_client( - base_url: str, - *, - user_id: Optional[str] = None, - force_oauth: bool = False, - reauthorize: Optional[Callable[[], Awaitable[OAuthTokenSet]]] = None, -): - """ - Factory: OAuth client when enabled, else legacy static-token HTTP client. - - Priority: - 1. Legacy ``TOOLHIVE_MCP_BEARER_TOKEN`` / ``TOOLHIVE_MCP_API_KEY`` → :class:`ToolHiveMcpClient` - 2. ``NW_MCP_OAUTH_ENABLED=true`` or ``force_oauth`` → :class:`McpOAuthClient` - 3. Default → :class:`ToolHiveMcpClient` - """ - from agents.toolhive import ToolHiveMcpClient - - if legacy_static_mcp_token() and not force_oauth: - return ToolHiveMcpClient(base_url) - - if mcp_oauth_enabled() or force_oauth: - return McpOAuthClient(base_url, user_id=user_id, reauthorize=reauthorize) - - return ToolHiveMcpClient(base_url) - - -def create_http_mcp_clients_for_urls( - urls: List[str], - *, - user_id: Optional[str] = None, - reauthorize: Optional[Callable[[], Awaitable[OAuthTokenSet]]] = None, -) -> list: - return [create_http_mcp_client(u, user_id=user_id, reauthorize=reauthorize) for u in urls] +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +"""HTTP MCP client with OAuth 2.1 authorization (streamable HTTP transport).""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any, Awaitable, Callable, Dict, List, Optional + +import httpx + +from .config import McpClientConfig +from .env_config import ( + config_from_env, + legacy_static_mcp_token, + mcp_oauth_enabled, + mcp_oauth_user_id, +) +from .exceptions import McpOAuthFlowAborted, McpTokenRefreshError +from .oauth_flow import OAuthTokenSet +from .token_manager import TokenManager +from .token_storage import make_token_store + +logger = logging.getLogger("runtime.mcp_client.client") + + +class McpOAuthClient: + """ + Async MCP client over streamable HTTP with spec-compliant outbound OAuth. + + Implements ``list_tools`` / ``call_tool`` compatible with :class:`agents.toolhive.McpClient`. + """ + + def __init__( + self, + base_url: str, + *, + config: Optional[McpClientConfig] = None, + user_id: Optional[str] = None, + token_manager: Optional[TokenManager] = None, + http_client: Optional[httpx.AsyncClient] = None, + reauthorize: Optional[Callable[[], Awaitable[OAuthTokenSet]]] = None, + ) -> None: + self._base_url = base_url.rstrip("/") + self._config = config or config_from_env(server_url=base_url) + self._user_id = user_id or mcp_oauth_user_id() + self._session_id: Optional[str] = None + self._initialized = False + self._http = http_client + self._owns_http = http_client is None + self._token_manager = token_manager or TokenManager( + self._config, + user_id=self._user_id, + token_store=make_token_store(self._config), + reauthorize=reauthorize, + ) + + async def _get_client(self) -> httpx.AsyncClient: + if self._http is None: + self._http = httpx.AsyncClient(timeout=60.0, verify=True) + return self._http + + async def aclose(self) -> None: + if self._owns_http and self._http is not None: + await self._http.aclose() + self._http = None + + async def ensure_authorized(self, *, www_authenticate: Optional[str] = None) -> None: + """Obtain or refresh tokens; run authorization code flow if needed.""" + http = await self._get_client() + await self._token_manager.ensure_discovery(www_authenticate=www_authenticate) + await self._token_manager.get_bearer_token(http_client=http) + + async def _auth_headers(self) -> Dict[str, str]: + http = await self._get_client() + token = await self._token_manager.get_bearer_token(http_client=http) + return { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "Authorization": f"Bearer {token}", + } + + def _merge_headers(self, extra: Dict[str, str]) -> Dict[str, str]: + out = dict(extra) + if self._session_id: + out["Mcp-Session-Id"] = self._session_id + return out + + async def _request( + self, + method: str, + *, + json_body: Optional[Dict[str, Any]] = None, + retried_auth: bool = False, + ) -> httpx.Response: + client = await self._get_client() + headers = self._merge_headers(await self._auth_headers()) + resp = await client.request(method, self._base_url, json=json_body, headers=headers) + + if resp.status_code == 401 and not retried_auth: + www = resp.headers.get("WWW-Authenticate") + action = await self._token_manager.handle_mcp_response(401, www) + if action == "forbidden": + resp.raise_for_status() + if action == "reauthorize": + await self.ensure_authorized(www_authenticate=www) + return await self._request( + method, + json_body=json_body, + retried_auth=True, + ) + stored = self._token_manager.load_stored() + if stored and stored.refresh_token: + try: + await self._token_manager.refresh_tokens(stored, http_client=client) + return await self._request( + method, + json_body=json_body, + retried_auth=True, + ) + except McpTokenRefreshError: + self._token_manager.discard_tokens() + else: + self._token_manager.discard_tokens() + try: + await self._token_manager.get_bearer_token(http_client=client) + except McpOAuthFlowAborted: + await self.ensure_authorized(www_authenticate=www) + return await self._request( + method, + json_body=json_body, + retried_auth=True, + ) + + if resp.status_code == 403: + await self._token_manager.handle_mcp_response(403, None) + resp.raise_for_status() + + return resp + + async def _initialize(self) -> None: + init_payload = { + "jsonrpc": "2.0", + "id": str(uuid.uuid4()), + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "node-wire", "version": "1.0.0"}, + }, + } + resp = await self._request("POST", json_body=init_payload) + resp.raise_for_status() + session_id = resp.headers.get("Mcp-Session-Id") + if session_id: + self._session_id = session_id + data = resp.json() + if "error" in data: + raise RuntimeError(f"MCP initialize error: {data['error']}") + + notif = {"jsonrpc": "2.0", "method": "notifications/initialized"} + try: + await self._request("POST", json_body=notif) + except Exception: + pass + self._initialized = True + + async def _rpc(self, method: str, params: Dict[str, Any]) -> Any: + if not self._initialized: + await self._initialize() + + payload: Dict[str, Any] = { + "jsonrpc": "2.0", + "id": str(uuid.uuid4()), + "method": method, + "params": params, + } + resp = await self._request("POST", json_body=payload) + resp.raise_for_status() + data = resp.json() + if "error" in data: + raise RuntimeError(f"MCP error: {data['error']}") + return data.get("result") + + async def list_tools(self) -> List[Dict[str, Any]]: + result = await self._rpc("tools/list", {}) + return result.get("tools", []) + + async def call_tool(self, name: str, arguments: Dict[str, Any]) -> str: + result = await self._rpc("tools/call", {"name": name, "arguments": arguments}) + content = result.get("content", []) + if isinstance(content, list): + parts = [c.get("text", "") for c in content if c.get("type") == "text"] + return "\n".join(parts) + return str(content) + + +def create_http_mcp_client( + base_url: str, + *, + user_id: Optional[str] = None, + force_oauth: bool = False, + reauthorize: Optional[Callable[[], Awaitable[OAuthTokenSet]]] = None, +): + """ + Factory: OAuth client when enabled, else legacy static-token HTTP client. + + Priority: + 1. Legacy ``TOOLHIVE_MCP_BEARER_TOKEN`` / ``TOOLHIVE_MCP_API_KEY`` → :class:`ToolHiveMcpClient` + 2. ``NW_MCP_OAUTH_ENABLED=true`` or ``force_oauth`` → :class:`McpOAuthClient` + 3. Default → :class:`ToolHiveMcpClient` + """ + from agents.toolhive import ToolHiveMcpClient + + if legacy_static_mcp_token() and not force_oauth: + return ToolHiveMcpClient(base_url) + + if mcp_oauth_enabled() or force_oauth: + return McpOAuthClient(base_url, user_id=user_id, reauthorize=reauthorize) + + return ToolHiveMcpClient(base_url) + + +def create_http_mcp_clients_for_urls( + urls: List[str], + *, + user_id: Optional[str] = None, + reauthorize: Optional[Callable[[], Awaitable[OAuthTokenSet]]] = None, +) -> list: + return [create_http_mcp_client(u, user_id=user_id, reauthorize=reauthorize) for u in urls] diff --git a/src/node_wire_runtime/mcp_client/config.py b/src/node_wire_runtime/mcp_client/config.py index eb98e07..ed4755b 100644 --- a/src/node_wire_runtime/mcp_client/config.py +++ b/src/node_wire_runtime/mcp_client/config.py @@ -1,163 +1,163 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -"""Configuration for outbound MCP OAuth client (requirements Section 10).""" - -from __future__ import annotations - -from enum import Enum -from typing import Optional -from urllib.parse import urlsplit, urlunsplit - -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator - - -class RedirectMode(str, Enum): - LOOPBACK = "loopback" - CONFIGURED_URL = "configured-url" - - -class TokenStoreMode(str, Enum): - OS_KEYCHAIN = "os-keychain" - CONFIGURED_SECRET_STORE = "configured-secret-store" - - -class AuthDiscoveryConfig(BaseModel): - model_config = ConfigDict(populate_by_name=True) - - cache_ttl_seconds: int = Field( - default=3600, - alias="cacheTtlSeconds", - description="How long discovery metadata is cached before re-fetch.", - ) - - -class AuthDcrConfig(BaseModel): - model_config = ConfigDict(populate_by_name=True) - - enabled: bool = True - client_name: str = Field(default="node-wire MCP Client", alias="clientName") - - -class AuthClientConfig(BaseModel): - model_config = ConfigDict(populate_by_name=True) - - id: str = Field(default="", alias="clientId") - secret: str = Field(default="", alias="clientSecret") - - -class AuthRedirectConfig(BaseModel): - model_config = ConfigDict(populate_by_name=True) - - mode: RedirectMode = RedirectMode.LOOPBACK - url: str = Field( - default="http://127.0.0.1:0/callback", - description="Used when mode=configured-url; loopback ignores port 0 at runtime.", - ) - loopback_host: str = Field(default="127.0.0.1", alias="loopbackHost") - loopback_path: str = Field(default="/callback", alias="loopbackPath") - - -class AuthTokenConfig(BaseModel): - model_config = ConfigDict(populate_by_name=True) - - refresh_lead_seconds: int = Field(default=60, alias="refreshLeadSeconds") - store: TokenStoreMode = TokenStoreMode.OS_KEYCHAIN - - -class AuthConfig(BaseModel): - model_config = ConfigDict(populate_by_name=True) - - production: bool = Field( - default=False, - description=( - "When true, require configured-url HTTPS redirect and https:// MCP server URL; " - "HTTP loopback is disabled." - ), - ) - discovery: AuthDiscoveryConfig = Field(default_factory=AuthDiscoveryConfig) - dcr: AuthDcrConfig = Field(default_factory=AuthDcrConfig) - client: AuthClientConfig = Field(default_factory=AuthClientConfig) - scopes: str = "" - redirect: AuthRedirectConfig = Field(default_factory=AuthRedirectConfig) - token: AuthTokenConfig = Field(default_factory=AuthTokenConfig) - issuer_override: Optional[str] = Field( - default=None, - alias="issuerOverride", - description="Force a specific authorization server issuer from PRM list.", - ) - registration_store_path: Optional[str] = Field( - default=None, - alias="registrationStorePath", - description="Directory for persisted DCR client registrations per issuer.", - ) - - -class McpServerConfig(BaseModel): - url: str = Field(description="Canonical URL of the remote MCP server (resource indicator).") - - @field_validator("url") - @classmethod - def _must_be_http_url(cls, v: str) -> str: - parsed = urlsplit(v.strip()) - if parsed.scheme not in ("https", "http"): - raise ValueError("mcp.server.url must use http or https") - if not parsed.netloc: - raise ValueError("mcp.server.url must include a host") - return v.strip() - - -class McpClientConfig(BaseModel): - """ - Operator configuration per remote MCP server connection. - - Maps to requirements document Section 10. - """ - - model_config = ConfigDict(populate_by_name=True) - - server: McpServerConfig - auth: AuthConfig = Field(default_factory=AuthConfig) - - @model_validator(mode="after") - def _validate_production_hardening(self) -> McpClientConfig: - validate_production_hardening(self) - return self - - @property - def canonical_server_url(self) -> str: - """Normalized resource indicator (no fragment, no trailing slash on path root).""" - return canonicalize_mcp_server_url(self.server.url) - - -def validate_production_hardening(config: McpClientConfig) -> None: - """ - Enforce production OAuth profile: HTTPS MCP server, configured-url redirect only. - - HTTP loopback remains available when ``auth.production`` is false (default). - """ - if not config.auth.production: - return - - from .exceptions import McpOAuthConfigurationError - - if urlsplit(config.server.url).scheme != "https": - raise McpOAuthConfigurationError("auth.production requires mcp.server.url to use https://") - if config.auth.redirect.mode != RedirectMode.CONFIGURED_URL: - raise McpOAuthConfigurationError( - "auth.production requires auth.redirect.mode=configured-url " - "(HTTP loopback is disabled in production)" - ) - redirect_url = config.auth.redirect.url.strip() - if not redirect_url.startswith("https://"): - raise McpOAuthConfigurationError( - "auth.production requires auth.redirect.url to use https://" - ) - - -def canonicalize_mcp_server_url(url: str) -> str: - """Canonical MCP server URL for RFC 8707 ``resource`` parameter.""" - parsed = urlsplit(url.strip()) - path = parsed.path.rstrip("/") or "" - return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")) +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +"""Configuration for outbound MCP OAuth client (requirements Section 10).""" + +from __future__ import annotations + +from enum import Enum +from typing import Optional +from urllib.parse import urlsplit, urlunsplit + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +class RedirectMode(str, Enum): + LOOPBACK = "loopback" + CONFIGURED_URL = "configured-url" + + +class TokenStoreMode(str, Enum): + OS_KEYCHAIN = "os-keychain" + CONFIGURED_SECRET_STORE = "configured-secret-store" + + +class AuthDiscoveryConfig(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + cache_ttl_seconds: int = Field( + default=3600, + alias="cacheTtlSeconds", + description="How long discovery metadata is cached before re-fetch.", + ) + + +class AuthDcrConfig(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + enabled: bool = True + client_name: str = Field(default="node-wire MCP Client", alias="clientName") + + +class AuthClientConfig(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + id: str = Field(default="", alias="clientId") + secret: str = Field(default="", alias="clientSecret") + + +class AuthRedirectConfig(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + mode: RedirectMode = RedirectMode.LOOPBACK + url: str = Field( + default="http://127.0.0.1:0/callback", + description="Used when mode=configured-url; loopback ignores port 0 at runtime.", + ) + loopback_host: str = Field(default="127.0.0.1", alias="loopbackHost") + loopback_path: str = Field(default="/callback", alias="loopbackPath") + + +class AuthTokenConfig(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + refresh_lead_seconds: int = Field(default=60, alias="refreshLeadSeconds") + store: TokenStoreMode = TokenStoreMode.OS_KEYCHAIN + + +class AuthConfig(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + production: bool = Field( + default=False, + description=( + "When true, require configured-url HTTPS redirect and https:// MCP server URL; " + "HTTP loopback is disabled." + ), + ) + discovery: AuthDiscoveryConfig = Field(default_factory=AuthDiscoveryConfig) + dcr: AuthDcrConfig = Field(default_factory=AuthDcrConfig) + client: AuthClientConfig = Field(default_factory=AuthClientConfig) + scopes: str = "" + redirect: AuthRedirectConfig = Field(default_factory=AuthRedirectConfig) + token: AuthTokenConfig = Field(default_factory=AuthTokenConfig) + issuer_override: Optional[str] = Field( + default=None, + alias="issuerOverride", + description="Force a specific authorization server issuer from PRM list.", + ) + registration_store_path: Optional[str] = Field( + default=None, + alias="registrationStorePath", + description="Directory for persisted DCR client registrations per issuer.", + ) + + +class McpServerConfig(BaseModel): + url: str = Field(description="Canonical URL of the remote MCP server (resource indicator).") + + @field_validator("url") + @classmethod + def _must_be_http_url(cls, v: str) -> str: + parsed = urlsplit(v.strip()) + if parsed.scheme not in ("https", "http"): + raise ValueError("mcp.server.url must use http or https") + if not parsed.netloc: + raise ValueError("mcp.server.url must include a host") + return v.strip() + + +class McpClientConfig(BaseModel): + """ + Operator configuration per remote MCP server connection. + + Maps to requirements document Section 10. + """ + + model_config = ConfigDict(populate_by_name=True) + + server: McpServerConfig + auth: AuthConfig = Field(default_factory=AuthConfig) + + @model_validator(mode="after") + def _validate_production_hardening(self) -> McpClientConfig: + validate_production_hardening(self) + return self + + @property + def canonical_server_url(self) -> str: + """Normalized resource indicator (no fragment, no trailing slash on path root).""" + return canonicalize_mcp_server_url(self.server.url) + + +def validate_production_hardening(config: McpClientConfig) -> None: + """ + Enforce production OAuth profile: HTTPS MCP server, configured-url redirect only. + + HTTP loopback remains available when ``auth.production`` is false (default). + """ + if not config.auth.production: + return + + from .exceptions import McpOAuthConfigurationError + + if urlsplit(config.server.url).scheme != "https": + raise McpOAuthConfigurationError("auth.production requires mcp.server.url to use https://") + if config.auth.redirect.mode != RedirectMode.CONFIGURED_URL: + raise McpOAuthConfigurationError( + "auth.production requires auth.redirect.mode=configured-url " + "(HTTP loopback is disabled in production)" + ) + redirect_url = config.auth.redirect.url.strip() + if not redirect_url.startswith("https://"): + raise McpOAuthConfigurationError( + "auth.production requires auth.redirect.url to use https://" + ) + + +def canonicalize_mcp_server_url(url: str) -> str: + """Canonical MCP server URL for RFC 8707 ``resource`` parameter.""" + parsed = urlsplit(url.strip()) + path = parsed.path.rstrip("/") or "" + return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")) diff --git a/src/node_wire_runtime/mcp_client/dcr.py b/src/node_wire_runtime/mcp_client/dcr.py index 86ccd4a..1d87f09 100644 --- a/src/node_wire_runtime/mcp_client/dcr.py +++ b/src/node_wire_runtime/mcp_client/dcr.py @@ -1,181 +1,181 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -"""RFC 7591 Dynamic Client Registration for MCP OAuth client.""" - -from __future__ import annotations - -import logging -from datetime import datetime, timezone -from typing import List, Optional - -import httpx - -from .config import McpClientConfig, RedirectMode -from .discovery import DiscoveryResult -from .exceptions import McpOAuthConfigurationError, McpOAuthRegistrationError -from .storage import ClientRegistration, RegistrationStore - -logger = logging.getLogger("runtime.mcp_client.dcr") - -_DEFAULT_GRANT_TYPES = ["authorization_code", "refresh_token"] -_DEFAULT_RESPONSE_TYPES = ["code"] - - -def resolve_redirect_uris( - config: McpClientConfig, *, loopback_uri: Optional[str] = None -) -> List[str]: - """Redirect URIs for DCR and authorization requests.""" - if config.auth.production: - if config.auth.redirect.mode == RedirectMode.LOOPBACK: - raise McpOAuthConfigurationError( - "auth.production does not allow auth.redirect.mode=loopback" - ) - uri = config.auth.redirect.url.strip() - if not uri: - raise McpOAuthConfigurationError( - "auth.redirect.url is required for configured-url mode" - ) - if not uri.startswith("https://"): - raise McpOAuthConfigurationError( - "auth.production requires auth.redirect.url to use https://" - ) - return [uri] - - if config.auth.redirect.mode == RedirectMode.LOOPBACK: - if not loopback_uri: - raise McpOAuthConfigurationError( - "loopback redirect URI is required for DCR in loopback mode" - ) - return [loopback_uri] - uri = config.auth.redirect.url.strip() - if not uri: - raise McpOAuthConfigurationError("auth.redirect.url is required for configured-url mode") - if not uri.startswith("https://") and not _is_loopback_uri(uri): - raise McpOAuthConfigurationError( - "auth.redirect.url must be HTTPS unless it is a loopback IP literal" - ) - return [uri] - - -def _is_loopback_uri(uri: str) -> bool: - from urllib.parse import urlsplit - - host = urlsplit(uri).hostname or "" - return host in ("127.0.0.1", "localhost", "::1") - - -def token_endpoint_auth_method(config: McpClientConfig) -> str: - if config.auth.client.secret: - return "client_secret_basic" - return "none" - - -async def register_dynamic_client( - config: McpClientConfig, - discovery: DiscoveryResult, - *, - redirect_uris: List[str], - http_client: Optional[httpx.AsyncClient] = None, -) -> ClientRegistration: - """ - POST RFC 7591 registration to ``registration_endpoint``. - - Raises if DCR is disabled, endpoint missing, or registration fails. - """ - if not config.auth.dcr.enabled: - raise McpOAuthRegistrationError("Dynamic Client Registration is disabled in config") - endpoint = discovery.authorization_server.registration_endpoint - if not endpoint: - raise McpOAuthRegistrationError( - "Authorization server does not advertise registration_endpoint" - ) - - auth_method = token_endpoint_auth_method(config) - payload = { - "client_name": config.auth.dcr.client_name, - "redirect_uris": redirect_uris, - "grant_types": _DEFAULT_GRANT_TYPES, - "response_types": _DEFAULT_RESPONSE_TYPES, - "token_endpoint_auth_method": auth_method, - "scope": config.auth.scopes.strip(), - } - - own_client = http_client is None - client = http_client or httpx.AsyncClient(timeout=30.0, verify=True) - try: - resp = await client.post(endpoint, json=payload) - if resp.status_code not in (200, 201): - body = resp.text[:500] - raise McpOAuthRegistrationError(f"DCR failed with HTTP {resp.status_code}: {body}") - data = resp.json() - client_id = data.get("client_id") - if not client_id: - raise McpOAuthRegistrationError("DCR response missing client_id") - return ClientRegistration( - issuer=discovery.issuer, - client_id=str(client_id), - client_secret=data.get("client_secret"), - redirect_uris=tuple(redirect_uris), - token_endpoint_auth_method=str(data.get("token_endpoint_auth_method") or auth_method), - registered_at=datetime.now(timezone.utc).isoformat(), - ) - except httpx.HTTPError as exc: - raise McpOAuthRegistrationError(f"DCR HTTP error: {exc}") from exc - finally: - if own_client: - await client.aclose() - - -async def resolve_client_registration( - config: McpClientConfig, - discovery: DiscoveryResult, - *, - redirect_uris: List[str], - store: Optional[RegistrationStore] = None, - http_client: Optional[httpx.AsyncClient] = None, -) -> ClientRegistration: - """ - Return persisted or configured client registration; register via DCR when needed. - - Order: operator ``auth.client.id`` override → stored registration → DCR. - """ - issuer = discovery.issuer - if config.auth.client.id: - return ClientRegistration( - issuer=issuer, - client_id=config.auth.client.id, - client_secret=config.auth.client.secret or None, - redirect_uris=tuple(redirect_uris), - token_endpoint_auth_method=token_endpoint_auth_method(config), - registered_at="", - ) - - reg_store = store or RegistrationStore( - config.auth.registration_store_path, - ) - existing = reg_store.get(issuer) - if existing is not None: - if set(existing.redirect_uris) != set(redirect_uris): - logger.info( - "Redirect URIs changed for issuer %s; re-registering via DCR", - issuer, - ) - else: - return existing - - if discovery.authorization_server.registration_endpoint and config.auth.dcr.enabled: - registration = await register_dynamic_client( - config, - discovery, - redirect_uris=redirect_uris, - http_client=http_client, - ) - reg_store.save(registration) - return registration - - raise McpOAuthConfigurationError( - "No client_id configured and authorization server does not support DCR. " - "Set auth.client.id (and optional auth.client.secret) in configuration." - ) +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +"""RFC 7591 Dynamic Client Registration for MCP OAuth client.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import List, Optional + +import httpx + +from .config import McpClientConfig, RedirectMode +from .discovery import DiscoveryResult +from .exceptions import McpOAuthConfigurationError, McpOAuthRegistrationError +from .storage import ClientRegistration, RegistrationStore + +logger = logging.getLogger("runtime.mcp_client.dcr") + +_DEFAULT_GRANT_TYPES = ["authorization_code", "refresh_token"] +_DEFAULT_RESPONSE_TYPES = ["code"] + + +def resolve_redirect_uris( + config: McpClientConfig, *, loopback_uri: Optional[str] = None +) -> List[str]: + """Redirect URIs for DCR and authorization requests.""" + if config.auth.production: + if config.auth.redirect.mode == RedirectMode.LOOPBACK: + raise McpOAuthConfigurationError( + "auth.production does not allow auth.redirect.mode=loopback" + ) + uri = config.auth.redirect.url.strip() + if not uri: + raise McpOAuthConfigurationError( + "auth.redirect.url is required for configured-url mode" + ) + if not uri.startswith("https://"): + raise McpOAuthConfigurationError( + "auth.production requires auth.redirect.url to use https://" + ) + return [uri] + + if config.auth.redirect.mode == RedirectMode.LOOPBACK: + if not loopback_uri: + raise McpOAuthConfigurationError( + "loopback redirect URI is required for DCR in loopback mode" + ) + return [loopback_uri] + uri = config.auth.redirect.url.strip() + if not uri: + raise McpOAuthConfigurationError("auth.redirect.url is required for configured-url mode") + if not uri.startswith("https://") and not _is_loopback_uri(uri): + raise McpOAuthConfigurationError( + "auth.redirect.url must be HTTPS unless it is a loopback IP literal" + ) + return [uri] + + +def _is_loopback_uri(uri: str) -> bool: + from urllib.parse import urlsplit + + host = urlsplit(uri).hostname or "" + return host in ("127.0.0.1", "localhost", "::1") + + +def token_endpoint_auth_method(config: McpClientConfig) -> str: + if config.auth.client.secret: + return "client_secret_basic" + return "none" + + +async def register_dynamic_client( + config: McpClientConfig, + discovery: DiscoveryResult, + *, + redirect_uris: List[str], + http_client: Optional[httpx.AsyncClient] = None, +) -> ClientRegistration: + """ + POST RFC 7591 registration to ``registration_endpoint``. + + Raises if DCR is disabled, endpoint missing, or registration fails. + """ + if not config.auth.dcr.enabled: + raise McpOAuthRegistrationError("Dynamic Client Registration is disabled in config") + endpoint = discovery.authorization_server.registration_endpoint + if not endpoint: + raise McpOAuthRegistrationError( + "Authorization server does not advertise registration_endpoint" + ) + + auth_method = token_endpoint_auth_method(config) + payload = { + "client_name": config.auth.dcr.client_name, + "redirect_uris": redirect_uris, + "grant_types": _DEFAULT_GRANT_TYPES, + "response_types": _DEFAULT_RESPONSE_TYPES, + "token_endpoint_auth_method": auth_method, + "scope": config.auth.scopes.strip(), + } + + own_client = http_client is None + client = http_client or httpx.AsyncClient(timeout=30.0, verify=True) + try: + resp = await client.post(endpoint, json=payload) + if resp.status_code not in (200, 201): + body = resp.text[:500] + raise McpOAuthRegistrationError(f"DCR failed with HTTP {resp.status_code}: {body}") + data = resp.json() + client_id = data.get("client_id") + if not client_id: + raise McpOAuthRegistrationError("DCR response missing client_id") + return ClientRegistration( + issuer=discovery.issuer, + client_id=str(client_id), + client_secret=data.get("client_secret"), + redirect_uris=tuple(redirect_uris), + token_endpoint_auth_method=str(data.get("token_endpoint_auth_method") or auth_method), + registered_at=datetime.now(timezone.utc).isoformat(), + ) + except httpx.HTTPError as exc: + raise McpOAuthRegistrationError(f"DCR HTTP error: {exc}") from exc + finally: + if own_client: + await client.aclose() + + +async def resolve_client_registration( + config: McpClientConfig, + discovery: DiscoveryResult, + *, + redirect_uris: List[str], + store: Optional[RegistrationStore] = None, + http_client: Optional[httpx.AsyncClient] = None, +) -> ClientRegistration: + """ + Return persisted or configured client registration; register via DCR when needed. + + Order: operator ``auth.client.id`` override → stored registration → DCR. + """ + issuer = discovery.issuer + if config.auth.client.id: + return ClientRegistration( + issuer=issuer, + client_id=config.auth.client.id, + client_secret=config.auth.client.secret or None, + redirect_uris=tuple(redirect_uris), + token_endpoint_auth_method=token_endpoint_auth_method(config), + registered_at="", + ) + + reg_store = store or RegistrationStore( + config.auth.registration_store_path, + ) + existing = reg_store.get(issuer) + if existing is not None: + if set(existing.redirect_uris) != set(redirect_uris): + logger.info( + "Redirect URIs changed for issuer %s; re-registering via DCR", + issuer, + ) + else: + return existing + + if discovery.authorization_server.registration_endpoint and config.auth.dcr.enabled: + registration = await register_dynamic_client( + config, + discovery, + redirect_uris=redirect_uris, + http_client=http_client, + ) + reg_store.save(registration) + return registration + + raise McpOAuthConfigurationError( + "No client_id configured and authorization server does not support DCR. " + "Set auth.client.id (and optional auth.client.secret) in configuration." + ) diff --git a/src/node_wire_runtime/mcp_client/discovery.py b/src/node_wire_runtime/mcp_client/discovery.py index 92e5354..2ac01d5 100644 --- a/src/node_wire_runtime/mcp_client/discovery.py +++ b/src/node_wire_runtime/mcp_client/discovery.py @@ -1,289 +1,289 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -"""RFC 9728 Protected Resource Metadata and RFC 8414 Authorization Server Metadata.""" - -from __future__ import annotations - -import logging -import re -import time -from dataclasses import dataclass -from typing import Any, Dict, List, Optional -from urllib.parse import urljoin, urlsplit - -import httpx - -from .config import McpClientConfig, canonicalize_mcp_server_url -from .exceptions import McpOAuthDiscoveryError - -logger = logging.getLogger("runtime.mcp_client.discovery") - -_RESOURCE_METADATA_RE = re.compile( - r'resource_metadata\s*=\s*"([^"]+)"', - re.IGNORECASE, -) - - -@dataclass(frozen=True) -class ProtectedResourceMetadata: - """RFC 9728 document for an MCP protected resource.""" - - resource: str - authorization_servers: tuple[str, ...] - raw: Dict[str, Any] - - @classmethod - def from_json( - cls, data: Dict[str, Any], *, fallback_resource: str - ) -> ProtectedResourceMetadata: - servers = data.get("authorization_servers") - if not isinstance(servers, list) or not servers: - raise McpOAuthDiscoveryError( - "Protected Resource Metadata missing authorization_servers" - ) - resource = str(data.get("resource") or fallback_resource) - return cls( - resource=resource, - authorization_servers=tuple(str(s) for s in servers), - raw=dict(data), - ) - - -@dataclass(frozen=True) -class AuthorizationServerMetadata: - """RFC 8414 / OpenID Provider Metadata subset used by the MCP client.""" - - issuer: str - authorization_endpoint: str - token_endpoint: str - registration_endpoint: Optional[str] - scopes_supported: Optional[tuple[str, ...]] - raw: Dict[str, Any] - - @classmethod - def from_json(cls, data: Dict[str, Any]) -> AuthorizationServerMetadata: - issuer = data.get("issuer") - authz = data.get("authorization_endpoint") - token = data.get("token_endpoint") - if not issuer or not authz or not token: - raise McpOAuthDiscoveryError( - "Authorization Server Metadata missing issuer, authorization_endpoint, " - "or token_endpoint" - ) - scopes = data.get("scopes_supported") - scopes_tuple: Optional[tuple[str, ...]] = None - if isinstance(scopes, list): - scopes_tuple = tuple(str(s) for s in scopes) - reg = data.get("registration_endpoint") - return cls( - issuer=str(issuer).rstrip("/"), - authorization_endpoint=str(authz), - token_endpoint=str(token), - registration_endpoint=str(reg) if reg else None, - scopes_supported=scopes_tuple, - raw=dict(data), - ) - - -@dataclass -class DiscoveryResult: - """Combined discovery output for one MCP server URL.""" - - mcp_server_url: str - protected_resource: ProtectedResourceMetadata - authorization_server: AuthorizationServerMetadata - issuer: str - - -@dataclass -class _CacheEntry: - result: DiscoveryResult - expires_at: float - - -class DiscoveryCache: - """In-memory discovery cache keyed by canonical MCP server URL.""" - - def __init__(self, ttl_seconds: int) -> None: - self._ttl = max(1, ttl_seconds) - self._entries: Dict[str, _CacheEntry] = {} - - def get(self, mcp_server_url: str) -> Optional[DiscoveryResult]: - key = canonicalize_mcp_server_url(mcp_server_url) - entry = self._entries.get(key) - if entry is None: - return None - if time.monotonic() >= entry.expires_at: - del self._entries[key] - return None - return entry.result - - def set(self, result: DiscoveryResult) -> None: - key = canonicalize_mcp_server_url(result.mcp_server_url) - self._entries[key] = _CacheEntry( - result=result, - expires_at=time.monotonic() + self._ttl, - ) - - def invalidate(self, mcp_server_url: str) -> None: - key = canonicalize_mcp_server_url(mcp_server_url) - self._entries.pop(key, None) - - -def parse_resource_metadata_url(www_authenticate: Optional[str]) -> Optional[str]: - """Extract ``resource_metadata`` URL from a WWW-Authenticate Bearer challenge.""" - if not www_authenticate: - return None - match = _RESOURCE_METADATA_RE.search(www_authenticate) - if match: - return match.group(1).strip() - return None - - -def protected_resource_metadata_well_known_url(mcp_server_url: str) -> str: - """Derive PRM URL when no WWW-Authenticate challenge is available.""" - parsed = urlsplit(canonicalize_mcp_server_url(mcp_server_url)) - origin = f"{parsed.scheme}://{parsed.netloc}" - return urljoin(origin + "/", ".well-known/oauth-protected-resource") - - -def authorization_server_metadata_urls(issuer: str) -> List[str]: - """RFC 8414 primary URL, then OpenID Configuration fallback.""" - base = issuer.rstrip("/") - return [ - f"{base}/.well-known/oauth-authorization-server", - f"{base}/.well-known/openid-configuration", - ] - - -def select_issuer( - authorization_servers: tuple[str, ...], - *, - override: Optional[str] = None, -) -> str: - if override: - normalized = override.rstrip("/") - allowed = {s.rstrip("/") for s in authorization_servers} - if normalized not in allowed: - raise McpOAuthDiscoveryError( - f"Configured issuer override {override!r} is not listed in " - f"authorization_servers: {list(authorization_servers)}" - ) - return normalized - return authorization_servers[0].rstrip("/") - - -async def fetch_json( - client: httpx.AsyncClient, - url: str, - *, - context: str, -) -> Dict[str, Any]: - try: - resp = await client.get(url) - resp.raise_for_status() - data = resp.json() - except httpx.HTTPError as exc: - raise McpOAuthDiscoveryError(f"{context}: HTTP error fetching {url}") from exc - except ValueError as exc: - raise McpOAuthDiscoveryError(f"{context}: invalid JSON from {url}") from exc - if not isinstance(data, dict): - raise McpOAuthDiscoveryError(f"{context}: expected JSON object from {url}") - return data - - -async def fetch_protected_resource_metadata( - client: httpx.AsyncClient, - mcp_server_url: str, - *, - www_authenticate: Optional[str] = None, -) -> ProtectedResourceMetadata: - prm_url = parse_resource_metadata_url(www_authenticate) - if not prm_url: - prm_url = protected_resource_metadata_well_known_url(mcp_server_url) - data = await fetch_json( - client, - prm_url, - context="Protected Resource Metadata", - ) - return ProtectedResourceMetadata.from_json( - data, - fallback_resource=canonicalize_mcp_server_url(mcp_server_url), - ) - - -async def fetch_authorization_server_metadata( - client: httpx.AsyncClient, - issuer: str, -) -> AuthorizationServerMetadata: - errors: List[str] = [] - for url in authorization_server_metadata_urls(issuer): - try: - data = await fetch_json( - client, - url, - context="Authorization Server Metadata", - ) - meta = AuthorizationServerMetadata.from_json(data) - if meta.issuer.rstrip("/") != issuer.rstrip("/"): - raise McpOAuthDiscoveryError( - f"Issuer mismatch: metadata issuer {meta.issuer!r} != expected {issuer!r}" - ) - return meta - except McpOAuthDiscoveryError as exc: - errors.append(str(exc)) - continue - raise McpOAuthDiscoveryError( - f"Could not fetch Authorization Server Metadata for {issuer!r}: {'; '.join(errors)}" - ) - - -async def discover( - config: McpClientConfig, - *, - www_authenticate: Optional[str] = None, - cache: Optional[DiscoveryCache] = None, - http_client: Optional[httpx.AsyncClient] = None, -) -> DiscoveryResult: - """ - Full discovery chain: PRM (RFC 9728) then AS metadata (RFC 8414). - - Uses cache when provided and not expired. Pass ``www_authenticate`` from an MCP 401. - """ - mcp_url = config.canonical_server_url - if cache is not None: - cached = cache.get(mcp_url) - if cached is not None: - return cached - - own_client = http_client is None - client = http_client or httpx.AsyncClient(timeout=30.0, verify=True) - try: - prm = await fetch_protected_resource_metadata( - client, - mcp_url, - www_authenticate=www_authenticate, - ) - issuer = select_issuer( - prm.authorization_servers, - override=config.auth.issuer_override, - ) - as_meta = await fetch_authorization_server_metadata(client, issuer) - result = DiscoveryResult( - mcp_server_url=mcp_url, - protected_resource=prm, - authorization_server=as_meta, - issuer=issuer, - ) - if cache is not None: - cache.set(result) - return result - finally: - if own_client: - await client.aclose() - - -def discovery_cache_for_config(config: McpClientConfig) -> DiscoveryCache: - return DiscoveryCache(config.auth.discovery.cache_ttl_seconds) +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +"""RFC 9728 Protected Resource Metadata and RFC 8414 Authorization Server Metadata.""" + +from __future__ import annotations + +import logging +import re +import time +from dataclasses import dataclass +from typing import Any, Dict, List, Optional +from urllib.parse import urljoin, urlsplit + +import httpx + +from .config import McpClientConfig, canonicalize_mcp_server_url +from .exceptions import McpOAuthDiscoveryError + +logger = logging.getLogger("runtime.mcp_client.discovery") + +_RESOURCE_METADATA_RE = re.compile( + r'resource_metadata\s*=\s*"([^"]+)"', + re.IGNORECASE, +) + + +@dataclass(frozen=True) +class ProtectedResourceMetadata: + """RFC 9728 document for an MCP protected resource.""" + + resource: str + authorization_servers: tuple[str, ...] + raw: Dict[str, Any] + + @classmethod + def from_json( + cls, data: Dict[str, Any], *, fallback_resource: str + ) -> ProtectedResourceMetadata: + servers = data.get("authorization_servers") + if not isinstance(servers, list) or not servers: + raise McpOAuthDiscoveryError( + "Protected Resource Metadata missing authorization_servers" + ) + resource = str(data.get("resource") or fallback_resource) + return cls( + resource=resource, + authorization_servers=tuple(str(s) for s in servers), + raw=dict(data), + ) + + +@dataclass(frozen=True) +class AuthorizationServerMetadata: + """RFC 8414 / OpenID Provider Metadata subset used by the MCP client.""" + + issuer: str + authorization_endpoint: str + token_endpoint: str + registration_endpoint: Optional[str] + scopes_supported: Optional[tuple[str, ...]] + raw: Dict[str, Any] + + @classmethod + def from_json(cls, data: Dict[str, Any]) -> AuthorizationServerMetadata: + issuer = data.get("issuer") + authz = data.get("authorization_endpoint") + token = data.get("token_endpoint") + if not issuer or not authz or not token: + raise McpOAuthDiscoveryError( + "Authorization Server Metadata missing issuer, authorization_endpoint, " + "or token_endpoint" + ) + scopes = data.get("scopes_supported") + scopes_tuple: Optional[tuple[str, ...]] = None + if isinstance(scopes, list): + scopes_tuple = tuple(str(s) for s in scopes) + reg = data.get("registration_endpoint") + return cls( + issuer=str(issuer).rstrip("/"), + authorization_endpoint=str(authz), + token_endpoint=str(token), + registration_endpoint=str(reg) if reg else None, + scopes_supported=scopes_tuple, + raw=dict(data), + ) + + +@dataclass +class DiscoveryResult: + """Combined discovery output for one MCP server URL.""" + + mcp_server_url: str + protected_resource: ProtectedResourceMetadata + authorization_server: AuthorizationServerMetadata + issuer: str + + +@dataclass +class _CacheEntry: + result: DiscoveryResult + expires_at: float + + +class DiscoveryCache: + """In-memory discovery cache keyed by canonical MCP server URL.""" + + def __init__(self, ttl_seconds: int) -> None: + self._ttl = max(1, ttl_seconds) + self._entries: Dict[str, _CacheEntry] = {} + + def get(self, mcp_server_url: str) -> Optional[DiscoveryResult]: + key = canonicalize_mcp_server_url(mcp_server_url) + entry = self._entries.get(key) + if entry is None: + return None + if time.monotonic() >= entry.expires_at: + del self._entries[key] + return None + return entry.result + + def set(self, result: DiscoveryResult) -> None: + key = canonicalize_mcp_server_url(result.mcp_server_url) + self._entries[key] = _CacheEntry( + result=result, + expires_at=time.monotonic() + self._ttl, + ) + + def invalidate(self, mcp_server_url: str) -> None: + key = canonicalize_mcp_server_url(mcp_server_url) + self._entries.pop(key, None) + + +def parse_resource_metadata_url(www_authenticate: Optional[str]) -> Optional[str]: + """Extract ``resource_metadata`` URL from a WWW-Authenticate Bearer challenge.""" + if not www_authenticate: + return None + match = _RESOURCE_METADATA_RE.search(www_authenticate) + if match: + return match.group(1).strip() + return None + + +def protected_resource_metadata_well_known_url(mcp_server_url: str) -> str: + """Derive PRM URL when no WWW-Authenticate challenge is available.""" + parsed = urlsplit(canonicalize_mcp_server_url(mcp_server_url)) + origin = f"{parsed.scheme}://{parsed.netloc}" + return urljoin(origin + "/", ".well-known/oauth-protected-resource") + + +def authorization_server_metadata_urls(issuer: str) -> List[str]: + """RFC 8414 primary URL, then OpenID Configuration fallback.""" + base = issuer.rstrip("/") + return [ + f"{base}/.well-known/oauth-authorization-server", + f"{base}/.well-known/openid-configuration", + ] + + +def select_issuer( + authorization_servers: tuple[str, ...], + *, + override: Optional[str] = None, +) -> str: + if override: + normalized = override.rstrip("/") + allowed = {s.rstrip("/") for s in authorization_servers} + if normalized not in allowed: + raise McpOAuthDiscoveryError( + f"Configured issuer override {override!r} is not listed in " + f"authorization_servers: {list(authorization_servers)}" + ) + return normalized + return authorization_servers[0].rstrip("/") + + +async def fetch_json( + client: httpx.AsyncClient, + url: str, + *, + context: str, +) -> Dict[str, Any]: + try: + resp = await client.get(url) + resp.raise_for_status() + data = resp.json() + except httpx.HTTPError as exc: + raise McpOAuthDiscoveryError(f"{context}: HTTP error fetching {url}") from exc + except ValueError as exc: + raise McpOAuthDiscoveryError(f"{context}: invalid JSON from {url}") from exc + if not isinstance(data, dict): + raise McpOAuthDiscoveryError(f"{context}: expected JSON object from {url}") + return data + + +async def fetch_protected_resource_metadata( + client: httpx.AsyncClient, + mcp_server_url: str, + *, + www_authenticate: Optional[str] = None, +) -> ProtectedResourceMetadata: + prm_url = parse_resource_metadata_url(www_authenticate) + if not prm_url: + prm_url = protected_resource_metadata_well_known_url(mcp_server_url) + data = await fetch_json( + client, + prm_url, + context="Protected Resource Metadata", + ) + return ProtectedResourceMetadata.from_json( + data, + fallback_resource=canonicalize_mcp_server_url(mcp_server_url), + ) + + +async def fetch_authorization_server_metadata( + client: httpx.AsyncClient, + issuer: str, +) -> AuthorizationServerMetadata: + errors: List[str] = [] + for url in authorization_server_metadata_urls(issuer): + try: + data = await fetch_json( + client, + url, + context="Authorization Server Metadata", + ) + meta = AuthorizationServerMetadata.from_json(data) + if meta.issuer.rstrip("/") != issuer.rstrip("/"): + raise McpOAuthDiscoveryError( + f"Issuer mismatch: metadata issuer {meta.issuer!r} != expected {issuer!r}" + ) + return meta + except McpOAuthDiscoveryError as exc: + errors.append(str(exc)) + continue + raise McpOAuthDiscoveryError( + f"Could not fetch Authorization Server Metadata for {issuer!r}: {'; '.join(errors)}" + ) + + +async def discover( + config: McpClientConfig, + *, + www_authenticate: Optional[str] = None, + cache: Optional[DiscoveryCache] = None, + http_client: Optional[httpx.AsyncClient] = None, +) -> DiscoveryResult: + """ + Full discovery chain: PRM (RFC 9728) then AS metadata (RFC 8414). + + Uses cache when provided and not expired. Pass ``www_authenticate`` from an MCP 401. + """ + mcp_url = config.canonical_server_url + if cache is not None: + cached = cache.get(mcp_url) + if cached is not None: + return cached + + own_client = http_client is None + client = http_client or httpx.AsyncClient(timeout=30.0, verify=True) + try: + prm = await fetch_protected_resource_metadata( + client, + mcp_url, + www_authenticate=www_authenticate, + ) + issuer = select_issuer( + prm.authorization_servers, + override=config.auth.issuer_override, + ) + as_meta = await fetch_authorization_server_metadata(client, issuer) + result = DiscoveryResult( + mcp_server_url=mcp_url, + protected_resource=prm, + authorization_server=as_meta, + issuer=issuer, + ) + if cache is not None: + cache.set(result) + return result + finally: + if own_client: + await client.aclose() + + +def discovery_cache_for_config(config: McpClientConfig) -> DiscoveryCache: + return DiscoveryCache(config.auth.discovery.cache_ttl_seconds) diff --git a/src/node_wire_runtime/mcp_client/env_config.py b/src/node_wire_runtime/mcp_client/env_config.py index d98f9d1..8099b50 100644 --- a/src/node_wire_runtime/mcp_client/env_config.py +++ b/src/node_wire_runtime/mcp_client/env_config.py @@ -1,98 +1,98 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -"""Build :class:`McpClientConfig` from environment variables.""" - -from __future__ import annotations - -import os - -from .config import ( - AuthClientConfig, - AuthConfig, - AuthDiscoveryConfig, - AuthDcrConfig, - AuthRedirectConfig, - AuthTokenConfig, - McpClientConfig, - McpServerConfig, - RedirectMode, - TokenStoreMode, -) - - -def _truthy(val: str | None) -> bool: - if val is None: - return False - return val.strip().lower() in ("1", "true", "yes", "on") - - -def mcp_oauth_enabled() -> bool: - return _truthy(os.environ.get("NW_MCP_OAUTH_ENABLED")) - - -def legacy_static_mcp_token() -> str | None: - return ( - os.environ.get("TOOLHIVE_MCP_BEARER_TOKEN") or os.environ.get("TOOLHIVE_MCP_API_KEY") or "" - ).strip() or None - - -def mcp_oauth_user_id() -> str: - return (os.environ.get("NW_MCP_OAUTH_USER_ID") or "default-user").strip() - - -def config_from_env(*, server_url: str | None = None) -> McpClientConfig: - """ - Resolve MCP OAuth client config from env. - - ``server_url`` defaults to ``NW_MCP_SERVER_URL`` or the provided HTTP MCP base URL. - """ - url = (server_url or os.environ.get("NW_MCP_SERVER_URL") or "").strip() - if not url: - raise ValueError("MCP server URL required (argument or NW_MCP_SERVER_URL)") - - production = _truthy(os.environ.get("NW_MCP_OAUTH_PRODUCTION")) - default_redirect_mode = "configured-url" if production else "loopback" - redirect_mode_raw = ( - (os.environ.get("NW_MCP_OAUTH_REDIRECT_MODE") or default_redirect_mode).strip().lower() - ) - redirect_mode = ( - RedirectMode.CONFIGURED_URL - if redirect_mode_raw == "configured-url" - else RedirectMode.LOOPBACK - ) - token_store_raw = (os.environ.get("NW_MCP_OAUTH_TOKEN_STORE") or "os-keychain").strip().lower() - token_store = ( - TokenStoreMode.CONFIGURED_SECRET_STORE - if token_store_raw == "configured-secret-store" - else TokenStoreMode.OS_KEYCHAIN - ) - - return McpClientConfig( - server=McpServerConfig(url=url), - auth=AuthConfig( - production=production, - scopes=(os.environ.get("NW_MCP_OAUTH_SCOPES") or "").strip(), - client=AuthClientConfig( - clientId=(os.environ.get("NW_MCP_OAUTH_CLIENT_ID") or "").strip(), - clientSecret=(os.environ.get("NW_MCP_OAUTH_CLIENT_SECRET") or "").strip(), - ), - redirect=AuthRedirectConfig( - mode=redirect_mode, - url=(os.environ.get("NW_MCP_OAUTH_REDIRECT_URL") or "http://127.0.0.1:0/callback"), - loopbackHost=(os.environ.get("NW_MCP_OAUTH_LOOPBACK_HOST") or "127.0.0.1"), - loopbackPath=(os.environ.get("NW_MCP_OAUTH_LOOPBACK_PATH") or "/callback"), - ), - discovery=AuthDiscoveryConfig( - cacheTtlSeconds=int(os.environ.get("NW_MCP_OAUTH_DISCOVERY_TTL", "3600")), - ), - dcr=AuthDcrConfig(enabled=not _truthy(os.environ.get("NW_MCP_OAUTH_DCR_DISABLED"))), - token=AuthTokenConfig( - refreshLeadSeconds=int(os.environ.get("NW_MCP_OAUTH_REFRESH_LEAD", "60")), - store=token_store, - ), - registrationStorePath=(os.environ.get("NW_MCP_OAUTH_REGISTRATION_PATH") or None), - issuerOverride=(os.environ.get("NW_MCP_OAUTH_ISSUER_OVERRIDE") or None), - ), - ) +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +"""Build :class:`McpClientConfig` from environment variables.""" + +from __future__ import annotations + +import os + +from .config import ( + AuthClientConfig, + AuthConfig, + AuthDiscoveryConfig, + AuthDcrConfig, + AuthRedirectConfig, + AuthTokenConfig, + McpClientConfig, + McpServerConfig, + RedirectMode, + TokenStoreMode, +) + + +def _truthy(val: str | None) -> bool: + if val is None: + return False + return val.strip().lower() in ("1", "true", "yes", "on") + + +def mcp_oauth_enabled() -> bool: + return _truthy(os.environ.get("NW_MCP_OAUTH_ENABLED")) + + +def legacy_static_mcp_token() -> str | None: + return ( + os.environ.get("TOOLHIVE_MCP_BEARER_TOKEN") or os.environ.get("TOOLHIVE_MCP_API_KEY") or "" + ).strip() or None + + +def mcp_oauth_user_id() -> str: + return (os.environ.get("NW_MCP_OAUTH_USER_ID") or "default-user").strip() + + +def config_from_env(*, server_url: str | None = None) -> McpClientConfig: + """ + Resolve MCP OAuth client config from env. + + ``server_url`` defaults to ``NW_MCP_SERVER_URL`` or the provided HTTP MCP base URL. + """ + url = (server_url or os.environ.get("NW_MCP_SERVER_URL") or "").strip() + if not url: + raise ValueError("MCP server URL required (argument or NW_MCP_SERVER_URL)") + + production = _truthy(os.environ.get("NW_MCP_OAUTH_PRODUCTION")) + default_redirect_mode = "configured-url" if production else "loopback" + redirect_mode_raw = ( + (os.environ.get("NW_MCP_OAUTH_REDIRECT_MODE") or default_redirect_mode).strip().lower() + ) + redirect_mode = ( + RedirectMode.CONFIGURED_URL + if redirect_mode_raw == "configured-url" + else RedirectMode.LOOPBACK + ) + token_store_raw = (os.environ.get("NW_MCP_OAUTH_TOKEN_STORE") or "os-keychain").strip().lower() + token_store = ( + TokenStoreMode.CONFIGURED_SECRET_STORE + if token_store_raw == "configured-secret-store" + else TokenStoreMode.OS_KEYCHAIN + ) + + return McpClientConfig( + server=McpServerConfig(url=url), + auth=AuthConfig( + production=production, + scopes=(os.environ.get("NW_MCP_OAUTH_SCOPES") or "").strip(), + client=AuthClientConfig( + clientId=(os.environ.get("NW_MCP_OAUTH_CLIENT_ID") or "").strip(), + clientSecret=(os.environ.get("NW_MCP_OAUTH_CLIENT_SECRET") or "").strip(), + ), + redirect=AuthRedirectConfig( + mode=redirect_mode, + url=(os.environ.get("NW_MCP_OAUTH_REDIRECT_URL") or "http://127.0.0.1:0/callback"), + loopbackHost=(os.environ.get("NW_MCP_OAUTH_LOOPBACK_HOST") or "127.0.0.1"), + loopbackPath=(os.environ.get("NW_MCP_OAUTH_LOOPBACK_PATH") or "/callback"), + ), + discovery=AuthDiscoveryConfig( + cacheTtlSeconds=int(os.environ.get("NW_MCP_OAUTH_DISCOVERY_TTL", "3600")), + ), + dcr=AuthDcrConfig(enabled=not _truthy(os.environ.get("NW_MCP_OAUTH_DCR_DISABLED"))), + token=AuthTokenConfig( + refreshLeadSeconds=int(os.environ.get("NW_MCP_OAUTH_REFRESH_LEAD", "60")), + store=token_store, + ), + registrationStorePath=(os.environ.get("NW_MCP_OAUTH_REGISTRATION_PATH") or None), + issuerOverride=(os.environ.get("NW_MCP_OAUTH_ISSUER_OVERRIDE") or None), + ), + ) diff --git a/src/node_wire_runtime/mcp_client/exceptions.py b/src/node_wire_runtime/mcp_client/exceptions.py index 503c0c9..a50fd58 100644 --- a/src/node_wire_runtime/mcp_client/exceptions.py +++ b/src/node_wire_runtime/mcp_client/exceptions.py @@ -1,39 +1,39 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -"""Exceptions for outbound MCP OAuth client (spec 2025-11-25).""" - -from __future__ import annotations - - -class McpOAuthError(Exception): - """Base error for MCP outbound OAuth client operations.""" - - -class McpOAuthDiscoveryError(McpOAuthError): - """Protected resource or authorization server metadata discovery failed.""" - - -class McpOAuthRegistrationError(McpOAuthError): - """Dynamic client registration (RFC 7591) failed.""" - - -class McpOAuthFlowAborted(McpOAuthError): - """User authorization flow aborted or rejected (e.g. state mismatch).""" - - -class McpOAuthSecurityError(McpOAuthError): - """Security violation during OAuth (CSRF, redirect, PKCE, audience).""" - - -class McpTokenRefreshError(McpOAuthError): - """Token refresh failed; caller should restart authorization code flow.""" - - -class McpAudienceMismatch(McpOAuthSecurityError): - """JWT access token audience does not match target MCP server URL.""" - - -class McpOAuthConfigurationError(McpOAuthError): - """Operator configuration is incomplete or invalid.""" +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +"""Exceptions for outbound MCP OAuth client (spec 2025-11-25).""" + +from __future__ import annotations + + +class McpOAuthError(Exception): + """Base error for MCP outbound OAuth client operations.""" + + +class McpOAuthDiscoveryError(McpOAuthError): + """Protected resource or authorization server metadata discovery failed.""" + + +class McpOAuthRegistrationError(McpOAuthError): + """Dynamic client registration (RFC 7591) failed.""" + + +class McpOAuthFlowAborted(McpOAuthError): + """User authorization flow aborted or rejected (e.g. state mismatch).""" + + +class McpOAuthSecurityError(McpOAuthError): + """Security violation during OAuth (CSRF, redirect, PKCE, audience).""" + + +class McpTokenRefreshError(McpOAuthError): + """Token refresh failed; caller should restart authorization code flow.""" + + +class McpAudienceMismatch(McpOAuthSecurityError): + """JWT access token audience does not match target MCP server URL.""" + + +class McpOAuthConfigurationError(McpOAuthError): + """Operator configuration is incomplete or invalid.""" diff --git a/src/node_wire_runtime/mcp_client/oauth_flow.py b/src/node_wire_runtime/mcp_client/oauth_flow.py index 0cd72c9..6b0b9e8 100644 --- a/src/node_wire_runtime/mcp_client/oauth_flow.py +++ b/src/node_wire_runtime/mcp_client/oauth_flow.py @@ -1,364 +1,364 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -"""Authorization code flow with PKCE (RFC 7636) and resource indicators (RFC 8707).""" - -from __future__ import annotations - -import base64 -import hashlib -import logging -import secrets -import webbrowser -from dataclasses import dataclass -from typing import Dict, Optional, Tuple -from urllib.parse import urlencode, urlsplit, parse_qs - -import httpx - -from .config import McpClientConfig, RedirectMode -from .discovery import DiscoveryResult, discover, discovery_cache_for_config -from .dcr import resolve_client_registration, resolve_redirect_uris -from .exceptions import ( - McpOAuthConfigurationError, - McpOAuthFlowAborted, - McpOAuthSecurityError, -) -from .redirect_listener import AuthorizationCallback, LoopbackRedirectListener -from .storage import ClientRegistration, RegistrationStore - -logger = logging.getLogger("runtime.mcp_client.oauth_flow") - -_PKCE_UNRESERVED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" - - -@dataclass(frozen=True) -class PkcePair: - code_verifier: str - code_challenge: str - - -@dataclass(frozen=True) -class AuthorizationSession: - """In-flight authorization state bound to a user session.""" - - state: str - pkce: PkcePair - redirect_uri: str - resource: str - - -@dataclass(frozen=True) -class OAuthTokenSet: - """Token response from authorization server (Section 6.1 step 8).""" - - access_token: str - token_type: str - expires_in: Optional[int] - refresh_token: Optional[str] - scope: Optional[str] - raw: Dict[str, object] - - -def generate_pkce_pair() -> PkcePair: - """RFC 7636 PKCE with S256 only (43–128 unreserved characters).""" - verifier = "".join(secrets.choice(_PKCE_UNRESERVED) for _ in range(64)) - digest = hashlib.sha256(verifier.encode("ascii")).digest() - challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") - return PkcePair(code_verifier=verifier, code_challenge=challenge) - - -def generate_state() -> str: - """Cryptographically random state with at least 128 bits of entropy.""" - return secrets.token_urlsafe(16) - - -def build_authorization_url( - *, - authorization_endpoint: str, - client_id: str, - redirect_uri: str, - scope: str, - state: str, - pkce: PkcePair, - resource: str, -) -> str: - """Construct authorization request URL (Section 6.1 step 3).""" - params = { - "response_type": "code", - "client_id": client_id, - "redirect_uri": redirect_uri, - "code_challenge": pkce.code_challenge, - "code_challenge_method": "S256", - "state": state, - "resource": resource, - } - if scope.strip(): - params["scope"] = scope.strip() - sep = "&" if "?" in authorization_endpoint else "?" - return f"{authorization_endpoint}{sep}{urlencode(params)}" - - -class AuthorizationCodeFlow: - """ - MCP authorization code + PKCE flow for a single MCP server configuration. - - Returns :class:`OAuthTokenSet`; persistence is handled by :class:`TokenManager`. - """ - - def __init__( - self, - config: McpClientConfig, - *, - discovery: Optional[DiscoveryResult] = None, - registration: Optional[ClientRegistration] = None, - registration_store: Optional[RegistrationStore] = None, - ) -> None: - self._config = config - self._discovery = discovery - self._registration = registration - self._registration_store = registration_store - self._pending_session: Optional[AuthorizationSession] = None - - @property - def pending_session(self) -> Optional[AuthorizationSession]: - return self._pending_session - - async def ensure_discovery(self, *, www_authenticate: Optional[str] = None) -> DiscoveryResult: - if self._discovery is not None: - return self._discovery - cache = discovery_cache_for_config(self._config) - self._discovery = await discover( - self._config, - www_authenticate=www_authenticate, - cache=cache, - ) - return self._discovery - - async def prepare_authorization_session( - self, - *, - redirect_uri: str, - ) -> Tuple[AuthorizationSession, str]: - """Create PKCE/state session and return (session, authorize_url).""" - discovery = await self.ensure_discovery() - registration = self._registration - if registration is None: - registration = await resolve_client_registration( - self._config, - discovery, - redirect_uris=[redirect_uri], - store=self._registration_store, - ) - self._registration = registration - - pkce = generate_pkce_pair() - state = generate_state() - session = AuthorizationSession( - state=state, - pkce=pkce, - redirect_uri=redirect_uri, - resource=self._config.canonical_server_url, - ) - self._pending_session = session - url = build_authorization_url( - authorization_endpoint=discovery.authorization_server.authorization_endpoint, - client_id=registration.client_id, - redirect_uri=redirect_uri, - scope=self._config.auth.scopes, - state=state, - pkce=pkce, - resource=session.resource, - ) - return session, url - - def validate_callback( - self, - callback: AuthorizationCallback, - *, - expected_state: str, - ) -> str: - """Validate state and return authorization code.""" - if callback.state != expected_state: - raise McpOAuthSecurityError("OAuth state mismatch — possible CSRF") - if callback.error: - raise McpOAuthFlowAborted(callback.error_description or callback.error) - if not callback.code: - raise McpOAuthFlowAborted("Authorization callback missing code") - return callback.code - - async def exchange_code( - self, - code: str, - *, - session: AuthorizationSession, - http_client: Optional[httpx.AsyncClient] = None, - ) -> OAuthTokenSet: - """Exchange authorization code at token endpoint (Section 6.1 step 7).""" - discovery = await self.ensure_discovery() - if self._registration is None: - raise McpOAuthConfigurationError("Client registration not resolved") - - data = { - "grant_type": "authorization_code", - "code": code, - "redirect_uri": session.redirect_uri, - "client_id": self._registration.client_id, - "code_verifier": session.pkce.code_verifier, - "resource": session.resource, - } - - own_client = http_client is None - client = http_client or httpx.AsyncClient(timeout=30.0, verify=True) - try: - headers: Dict[str, str] = {} - auth = _basic_auth_header(self._registration) - if auth: - headers["Authorization"] = auth - resp = await client.post( - discovery.authorization_server.token_endpoint, - data=data, - headers=headers, - ) - if resp.status_code != 200: - raise McpOAuthFlowAborted(f"Token exchange failed with HTTP {resp.status_code}") - body = resp.json() - access = body.get("access_token") - if not access: - raise McpOAuthFlowAborted("Token response missing access_token") - return OAuthTokenSet( - access_token=str(access), - token_type=str(body.get("token_type") or "Bearer"), - expires_in=_optional_int(body.get("expires_in")), - refresh_token=body.get("refresh_token"), - scope=body.get("scope"), - raw=body, - ) - except httpx.HTTPError as exc: - raise McpOAuthFlowAborted(f"Token exchange HTTP error: {exc}") from exc - finally: - if own_client: - await client.aclose() - - async def run_loopback_authorization( - self, - *, - open_browser: bool = True, - timeout: float = 300.0, - ) -> OAuthTokenSet: - """ - Full loopback flow: listen → browser → callback → token exchange. - - For ``configured-url`` mode use :meth:`start_authorization` and - :meth:`complete_authorization_with_callback_url` instead. - """ - if self._config.auth.production: - raise McpOAuthConfigurationError( - "auth.production disables HTTP loopback; use configured-url mode with " - "start_authorization / complete_authorization_with_callback_url" - ) - if self._config.auth.redirect.mode != RedirectMode.LOOPBACK: - raise McpOAuthConfigurationError( - "run_loopback_authorization requires auth.redirect.mode=loopback" - ) - - listener = LoopbackRedirectListener( - host=self._config.auth.redirect.loopback_host, - path=self._config.auth.redirect.loopback_path, - ) - binding = await listener.start() - try: - session, authorize_url = await self.prepare_authorization_session( - redirect_uri=binding.redirect_uri, - ) - if open_browser: - webbrowser.open(authorize_url) - else: - logger.info("Open this URL to authorize: %s", authorize_url) - - callback = await listener.wait_for_callback(timeout=timeout) - code = self.validate_callback(callback, expected_state=session.state) - return await self.exchange_code(code, session=session) - finally: - await listener.close() - - async def start_authorization( - self, - *, - redirect_uri: Optional[str] = None, - open_browser: bool = False, - ) -> str: - """ - Begin authorization for configured-url mode; returns URL to open in browser. - - Operator must later call :meth:`complete_authorization_with_callback_url`. - """ - if self._config.auth.redirect.mode == RedirectMode.LOOPBACK: - raise McpOAuthConfigurationError( - "start_authorization is for configured-url mode; use run_loopback_authorization" - ) - uris = resolve_redirect_uris(self._config) - uri = redirect_uri or uris[0] - session, url = await self.prepare_authorization_session(redirect_uri=uri) - if open_browser: - webbrowser.open(url) - return url - - async def complete_authorization_with_callback_url( - self, - callback_url: str, - ) -> OAuthTokenSet: - """Complete flow from full redirect URL (configured-url deployments).""" - session = self._pending_session - if session is None: - raise McpOAuthConfigurationError( - "No pending authorization session; call start_authorization first" - ) - - parsed = urlsplit(callback_url.strip()) - if parsed.scheme and parsed.netloc: - redirect_base = urlsplit(session.redirect_uri) - if ( - parsed.scheme != redirect_base.scheme - or parsed.netloc != redirect_base.netloc - or parsed.path != redirect_base.path - ): - raise McpOAuthSecurityError("Callback URL does not match registered redirect_uri") - - qs = parse_qs(parsed.query) - callback = AuthorizationCallback( - code=_qs_first(qs, "code"), - state=_qs_first(qs, "state"), - error=_qs_first(qs, "error"), - error_description=_qs_first(qs, "error_description"), - ) - code = self.validate_callback(callback, expected_state=session.state) - tokens = await self.exchange_code(code, session=session) - self._pending_session = None - return tokens - - -def _qs_first(qs: dict, key: str) -> Optional[str]: - vals = qs.get(key) - return vals[0] if vals else None - - -def _optional_int(value: object) -> Optional[int]: - if value is None or isinstance(value, bool): - return None - if not isinstance(value, (int, str, float)): - return None - try: - return int(value) - except (TypeError, ValueError): - return None - - -def _basic_auth_header(registration: ClientRegistration) -> Optional[str]: - if not registration.client_secret: - return None - import base64 as b64 - - raw = f"{registration.client_id}:{registration.client_secret}".encode() - return "Basic " + b64.standard_b64encode(raw).decode("ascii") +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +"""Authorization code flow with PKCE (RFC 7636) and resource indicators (RFC 8707).""" + +from __future__ import annotations + +import base64 +import hashlib +import logging +import secrets +import webbrowser +from dataclasses import dataclass +from typing import Dict, Optional, Tuple +from urllib.parse import urlencode, urlsplit, parse_qs + +import httpx + +from .config import McpClientConfig, RedirectMode +from .discovery import DiscoveryResult, discover, discovery_cache_for_config +from .dcr import resolve_client_registration, resolve_redirect_uris +from .exceptions import ( + McpOAuthConfigurationError, + McpOAuthFlowAborted, + McpOAuthSecurityError, +) +from .redirect_listener import AuthorizationCallback, LoopbackRedirectListener +from .storage import ClientRegistration, RegistrationStore + +logger = logging.getLogger("runtime.mcp_client.oauth_flow") + +_PKCE_UNRESERVED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + + +@dataclass(frozen=True) +class PkcePair: + code_verifier: str + code_challenge: str + + +@dataclass(frozen=True) +class AuthorizationSession: + """In-flight authorization state bound to a user session.""" + + state: str + pkce: PkcePair + redirect_uri: str + resource: str + + +@dataclass(frozen=True) +class OAuthTokenSet: + """Token response from authorization server (Section 6.1 step 8).""" + + access_token: str + token_type: str + expires_in: Optional[int] + refresh_token: Optional[str] + scope: Optional[str] + raw: Dict[str, object] + + +def generate_pkce_pair() -> PkcePair: + """RFC 7636 PKCE with S256 only (43–128 unreserved characters).""" + verifier = "".join(secrets.choice(_PKCE_UNRESERVED) for _ in range(64)) + digest = hashlib.sha256(verifier.encode("ascii")).digest() + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return PkcePair(code_verifier=verifier, code_challenge=challenge) + + +def generate_state() -> str: + """Cryptographically random state with at least 128 bits of entropy.""" + return secrets.token_urlsafe(16) + + +def build_authorization_url( + *, + authorization_endpoint: str, + client_id: str, + redirect_uri: str, + scope: str, + state: str, + pkce: PkcePair, + resource: str, +) -> str: + """Construct authorization request URL (Section 6.1 step 3).""" + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "code_challenge": pkce.code_challenge, + "code_challenge_method": "S256", + "state": state, + "resource": resource, + } + if scope.strip(): + params["scope"] = scope.strip() + sep = "&" if "?" in authorization_endpoint else "?" + return f"{authorization_endpoint}{sep}{urlencode(params)}" + + +class AuthorizationCodeFlow: + """ + MCP authorization code + PKCE flow for a single MCP server configuration. + + Returns :class:`OAuthTokenSet`; persistence is handled by :class:`TokenManager`. + """ + + def __init__( + self, + config: McpClientConfig, + *, + discovery: Optional[DiscoveryResult] = None, + registration: Optional[ClientRegistration] = None, + registration_store: Optional[RegistrationStore] = None, + ) -> None: + self._config = config + self._discovery = discovery + self._registration = registration + self._registration_store = registration_store + self._pending_session: Optional[AuthorizationSession] = None + + @property + def pending_session(self) -> Optional[AuthorizationSession]: + return self._pending_session + + async def ensure_discovery(self, *, www_authenticate: Optional[str] = None) -> DiscoveryResult: + if self._discovery is not None: + return self._discovery + cache = discovery_cache_for_config(self._config) + self._discovery = await discover( + self._config, + www_authenticate=www_authenticate, + cache=cache, + ) + return self._discovery + + async def prepare_authorization_session( + self, + *, + redirect_uri: str, + ) -> Tuple[AuthorizationSession, str]: + """Create PKCE/state session and return (session, authorize_url).""" + discovery = await self.ensure_discovery() + registration = self._registration + if registration is None: + registration = await resolve_client_registration( + self._config, + discovery, + redirect_uris=[redirect_uri], + store=self._registration_store, + ) + self._registration = registration + + pkce = generate_pkce_pair() + state = generate_state() + session = AuthorizationSession( + state=state, + pkce=pkce, + redirect_uri=redirect_uri, + resource=self._config.canonical_server_url, + ) + self._pending_session = session + url = build_authorization_url( + authorization_endpoint=discovery.authorization_server.authorization_endpoint, + client_id=registration.client_id, + redirect_uri=redirect_uri, + scope=self._config.auth.scopes, + state=state, + pkce=pkce, + resource=session.resource, + ) + return session, url + + def validate_callback( + self, + callback: AuthorizationCallback, + *, + expected_state: str, + ) -> str: + """Validate state and return authorization code.""" + if callback.state != expected_state: + raise McpOAuthSecurityError("OAuth state mismatch — possible CSRF") + if callback.error: + raise McpOAuthFlowAborted(callback.error_description or callback.error) + if not callback.code: + raise McpOAuthFlowAborted("Authorization callback missing code") + return callback.code + + async def exchange_code( + self, + code: str, + *, + session: AuthorizationSession, + http_client: Optional[httpx.AsyncClient] = None, + ) -> OAuthTokenSet: + """Exchange authorization code at token endpoint (Section 6.1 step 7).""" + discovery = await self.ensure_discovery() + if self._registration is None: + raise McpOAuthConfigurationError("Client registration not resolved") + + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": session.redirect_uri, + "client_id": self._registration.client_id, + "code_verifier": session.pkce.code_verifier, + "resource": session.resource, + } + + own_client = http_client is None + client = http_client or httpx.AsyncClient(timeout=30.0, verify=True) + try: + headers: Dict[str, str] = {} + auth = _basic_auth_header(self._registration) + if auth: + headers["Authorization"] = auth + resp = await client.post( + discovery.authorization_server.token_endpoint, + data=data, + headers=headers, + ) + if resp.status_code != 200: + raise McpOAuthFlowAborted(f"Token exchange failed with HTTP {resp.status_code}") + body = resp.json() + access = body.get("access_token") + if not access: + raise McpOAuthFlowAborted("Token response missing access_token") + return OAuthTokenSet( + access_token=str(access), + token_type=str(body.get("token_type") or "Bearer"), + expires_in=_optional_int(body.get("expires_in")), + refresh_token=body.get("refresh_token"), + scope=body.get("scope"), + raw=body, + ) + except httpx.HTTPError as exc: + raise McpOAuthFlowAborted(f"Token exchange HTTP error: {exc}") from exc + finally: + if own_client: + await client.aclose() + + async def run_loopback_authorization( + self, + *, + open_browser: bool = True, + timeout: float = 300.0, + ) -> OAuthTokenSet: + """ + Full loopback flow: listen → browser → callback → token exchange. + + For ``configured-url`` mode use :meth:`start_authorization` and + :meth:`complete_authorization_with_callback_url` instead. + """ + if self._config.auth.production: + raise McpOAuthConfigurationError( + "auth.production disables HTTP loopback; use configured-url mode with " + "start_authorization / complete_authorization_with_callback_url" + ) + if self._config.auth.redirect.mode != RedirectMode.LOOPBACK: + raise McpOAuthConfigurationError( + "run_loopback_authorization requires auth.redirect.mode=loopback" + ) + + listener = LoopbackRedirectListener( + host=self._config.auth.redirect.loopback_host, + path=self._config.auth.redirect.loopback_path, + ) + binding = await listener.start() + try: + session, authorize_url = await self.prepare_authorization_session( + redirect_uri=binding.redirect_uri, + ) + if open_browser: + webbrowser.open(authorize_url) + else: + logger.info("Open this URL to authorize: %s", authorize_url) + + callback = await listener.wait_for_callback(timeout=timeout) + code = self.validate_callback(callback, expected_state=session.state) + return await self.exchange_code(code, session=session) + finally: + await listener.close() + + async def start_authorization( + self, + *, + redirect_uri: Optional[str] = None, + open_browser: bool = False, + ) -> str: + """ + Begin authorization for configured-url mode; returns URL to open in browser. + + Operator must later call :meth:`complete_authorization_with_callback_url`. + """ + if self._config.auth.redirect.mode == RedirectMode.LOOPBACK: + raise McpOAuthConfigurationError( + "start_authorization is for configured-url mode; use run_loopback_authorization" + ) + uris = resolve_redirect_uris(self._config) + uri = redirect_uri or uris[0] + session, url = await self.prepare_authorization_session(redirect_uri=uri) + if open_browser: + webbrowser.open(url) + return url + + async def complete_authorization_with_callback_url( + self, + callback_url: str, + ) -> OAuthTokenSet: + """Complete flow from full redirect URL (configured-url deployments).""" + session = self._pending_session + if session is None: + raise McpOAuthConfigurationError( + "No pending authorization session; call start_authorization first" + ) + + parsed = urlsplit(callback_url.strip()) + if parsed.scheme and parsed.netloc: + redirect_base = urlsplit(session.redirect_uri) + if ( + parsed.scheme != redirect_base.scheme + or parsed.netloc != redirect_base.netloc + or parsed.path != redirect_base.path + ): + raise McpOAuthSecurityError("Callback URL does not match registered redirect_uri") + + qs = parse_qs(parsed.query) + callback = AuthorizationCallback( + code=_qs_first(qs, "code"), + state=_qs_first(qs, "state"), + error=_qs_first(qs, "error"), + error_description=_qs_first(qs, "error_description"), + ) + code = self.validate_callback(callback, expected_state=session.state) + tokens = await self.exchange_code(code, session=session) + self._pending_session = None + return tokens + + +def _qs_first(qs: dict, key: str) -> Optional[str]: + vals = qs.get(key) + return vals[0] if vals else None + + +def _optional_int(value: object) -> Optional[int]: + if value is None or isinstance(value, bool): + return None + if not isinstance(value, (int, str, float)): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _basic_auth_header(registration: ClientRegistration) -> Optional[str]: + if not registration.client_secret: + return None + import base64 as b64 + + raw = f"{registration.client_id}:{registration.client_secret}".encode() + return "Basic " + b64.standard_b64encode(raw).decode("ascii") diff --git a/src/node_wire_runtime/mcp_client/redirect_listener.py b/src/node_wire_runtime/mcp_client/redirect_listener.py index ebf8def..ad9dfbd 100644 --- a/src/node_wire_runtime/mcp_client/redirect_listener.py +++ b/src/node_wire_runtime/mcp_client/redirect_listener.py @@ -1,191 +1,191 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -"""Loopback redirect URI listener for OAuth authorization code callback.""" - -from __future__ import annotations - -import asyncio -import logging -import socket -from dataclasses import dataclass -from typing import Optional -from urllib.parse import parse_qs, urlsplit - -from .exceptions import McpOAuthFlowAborted, McpOAuthSecurityError - -logger = logging.getLogger("runtime.mcp_client.redirect_listener") - -_HTML_SUCCESS = ( - b"

Authorization complete

" - b"

You can close this window and return to node-wire.

" -) -_HTML_ERROR = ( - b"

Authorization failed

" - b"

Return to node-wire and try again.

" -) - - -@dataclass(frozen=True) -class AuthorizationCallback: - """Query parameters from the redirect URI callback.""" - - code: Optional[str] - state: Optional[str] - error: Optional[str] - error_description: Optional[str] - - -@dataclass(frozen=True) -class LoopbackRedirectBinding: - """Ephemeral loopback redirect URI bound to a single callback.""" - - redirect_uri: str - host: str - port: int - path: str - - -def _normalize_path(path: str) -> str: - p = path.strip() or "/callback" - return p if p.startswith("/") else f"/{p}" - - -class LoopbackRedirectListener: - """ - Bind ``127.0.0.1`` on an ephemeral port and accept exactly one HTTP callback. - - Per MCP security requirements: exact path match, single callback, then close. - """ - - def __init__( - self, - *, - host: str = "127.0.0.1", - path: str = "/callback", - ) -> None: - self._host = host - self._path = _normalize_path(path) - self._server: Optional[asyncio.AbstractServer] = None - self._binding: Optional[LoopbackRedirectBinding] = None - self._callback_future: Optional[asyncio.Future[AuthorizationCallback]] = None - - @property - def binding(self) -> LoopbackRedirectBinding: - if self._binding is None: - raise RuntimeError("Listener not started; call start() first") - return self._binding - - async def start(self) -> LoopbackRedirectBinding: - loop = asyncio.get_running_loop() - self._callback_future = loop.create_future() - - # Bind explicit loopback address on an ephemeral port. - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind((self._host, 0)) - sock.listen(1) - port = sock.getsockname()[1] - self._binding = LoopbackRedirectBinding( - redirect_uri=f"http://{self._host}:{port}{self._path}", - host=self._host, - port=port, - path=self._path, - ) - - async def _handle( - reader: asyncio.StreamReader, - writer: asyncio.StreamWriter, - ) -> None: - try: - request_line = await reader.readline() - if not request_line: - return - parts = request_line.decode("latin-1", errors="replace").split() - if len(parts) < 2: - return - method, target = parts[0], parts[1] - # Consume headers - while True: - line = await reader.readline() - if line in (b"\r\n", b"\n", b""): - break - - parsed = urlsplit(target) - if method.upper() != "GET": - writer.write(b"HTTP/1.1 405 Method Not Allowed\r\n\r\n") - await writer.drain() - return - - if parsed.path != self._path: - writer.write(b"HTTP/1.1 404 Not Found\r\n\r\n") - await writer.drain() - if self._callback_future and not self._callback_future.done(): - self._callback_future.set_exception( - McpOAuthSecurityError( - f"Redirect path mismatch: expected {self._path!r}, " - f"got {parsed.path!r}" - ) - ) - return - - qs = parse_qs(parsed.query) - callback = AuthorizationCallback( - code=_first(qs, "code"), - state=_first(qs, "state"), - error=_first(qs, "error"), - error_description=_first(qs, "error_description"), - ) - if callback.error: - body = _HTML_ERROR - if self._callback_future and not self._callback_future.done(): - self._callback_future.set_exception( - McpOAuthFlowAborted(callback.error_description or callback.error) - ) - else: - body = _HTML_SUCCESS - if self._callback_future and not self._callback_future.done(): - self._callback_future.set_result(callback) - - writer.write(b"HTTP/1.1 200 OK\r\n") - writer.write(b"Content-Type: text/html; charset=utf-8\r\n") - writer.write(f"Content-Length: {len(body)}\r\n\r\n".encode()) - writer.write(body) - await writer.drain() - finally: - writer.close() - try: - await writer.wait_closed() - except Exception: - pass - if self._server: - self._server.close() - - self._server = await asyncio.start_server(_handle, sock=sock) - logger.debug( - "Loopback redirect listener started", - extra={"redirect_uri": self._binding.redirect_uri}, - ) - return self._binding - - async def wait_for_callback(self, timeout: float = 300.0) -> AuthorizationCallback: - if self._callback_future is None: - raise RuntimeError("Listener not started") - try: - return await asyncio.wait_for(asyncio.shield(self._callback_future), timeout) - except asyncio.TimeoutError as exc: - raise McpOAuthFlowAborted("Authorization timed out waiting for redirect") from exc - - async def close(self) -> None: - if self._server is not None: - self._server.close() - await self._server.wait_closed() - self._server = None - - -def _first(qs: dict, key: str) -> Optional[str]: - vals = qs.get(key) - if vals: - return vals[0] - return None +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +"""Loopback redirect URI listener for OAuth authorization code callback.""" + +from __future__ import annotations + +import asyncio +import logging +import socket +from dataclasses import dataclass +from typing import Optional +from urllib.parse import parse_qs, urlsplit + +from .exceptions import McpOAuthFlowAborted, McpOAuthSecurityError + +logger = logging.getLogger("runtime.mcp_client.redirect_listener") + +_HTML_SUCCESS = ( + b"

Authorization complete

" + b"

You can close this window and return to node-wire.

" +) +_HTML_ERROR = ( + b"

Authorization failed

" + b"

Return to node-wire and try again.

" +) + + +@dataclass(frozen=True) +class AuthorizationCallback: + """Query parameters from the redirect URI callback.""" + + code: Optional[str] + state: Optional[str] + error: Optional[str] + error_description: Optional[str] + + +@dataclass(frozen=True) +class LoopbackRedirectBinding: + """Ephemeral loopback redirect URI bound to a single callback.""" + + redirect_uri: str + host: str + port: int + path: str + + +def _normalize_path(path: str) -> str: + p = path.strip() or "/callback" + return p if p.startswith("/") else f"/{p}" + + +class LoopbackRedirectListener: + """ + Bind ``127.0.0.1`` on an ephemeral port and accept exactly one HTTP callback. + + Per MCP security requirements: exact path match, single callback, then close. + """ + + def __init__( + self, + *, + host: str = "127.0.0.1", + path: str = "/callback", + ) -> None: + self._host = host + self._path = _normalize_path(path) + self._server: Optional[asyncio.AbstractServer] = None + self._binding: Optional[LoopbackRedirectBinding] = None + self._callback_future: Optional[asyncio.Future[AuthorizationCallback]] = None + + @property + def binding(self) -> LoopbackRedirectBinding: + if self._binding is None: + raise RuntimeError("Listener not started; call start() first") + return self._binding + + async def start(self) -> LoopbackRedirectBinding: + loop = asyncio.get_running_loop() + self._callback_future = loop.create_future() + + # Bind explicit loopback address on an ephemeral port. + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((self._host, 0)) + sock.listen(1) + port = sock.getsockname()[1] + self._binding = LoopbackRedirectBinding( + redirect_uri=f"http://{self._host}:{port}{self._path}", + host=self._host, + port=port, + path=self._path, + ) + + async def _handle( + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + try: + request_line = await reader.readline() + if not request_line: + return + parts = request_line.decode("latin-1", errors="replace").split() + if len(parts) < 2: + return + method, target = parts[0], parts[1] + # Consume headers + while True: + line = await reader.readline() + if line in (b"\r\n", b"\n", b""): + break + + parsed = urlsplit(target) + if method.upper() != "GET": + writer.write(b"HTTP/1.1 405 Method Not Allowed\r\n\r\n") + await writer.drain() + return + + if parsed.path != self._path: + writer.write(b"HTTP/1.1 404 Not Found\r\n\r\n") + await writer.drain() + if self._callback_future and not self._callback_future.done(): + self._callback_future.set_exception( + McpOAuthSecurityError( + f"Redirect path mismatch: expected {self._path!r}, " + f"got {parsed.path!r}" + ) + ) + return + + qs = parse_qs(parsed.query) + callback = AuthorizationCallback( + code=_first(qs, "code"), + state=_first(qs, "state"), + error=_first(qs, "error"), + error_description=_first(qs, "error_description"), + ) + if callback.error: + body = _HTML_ERROR + if self._callback_future and not self._callback_future.done(): + self._callback_future.set_exception( + McpOAuthFlowAborted(callback.error_description or callback.error) + ) + else: + body = _HTML_SUCCESS + if self._callback_future and not self._callback_future.done(): + self._callback_future.set_result(callback) + + writer.write(b"HTTP/1.1 200 OK\r\n") + writer.write(b"Content-Type: text/html; charset=utf-8\r\n") + writer.write(f"Content-Length: {len(body)}\r\n\r\n".encode()) + writer.write(body) + await writer.drain() + finally: + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + if self._server: + self._server.close() + + self._server = await asyncio.start_server(_handle, sock=sock) + logger.debug( + "Loopback redirect listener started", + extra={"redirect_uri": self._binding.redirect_uri}, + ) + return self._binding + + async def wait_for_callback(self, timeout: float = 300.0) -> AuthorizationCallback: + if self._callback_future is None: + raise RuntimeError("Listener not started") + try: + return await asyncio.wait_for(asyncio.shield(self._callback_future), timeout) + except asyncio.TimeoutError as exc: + raise McpOAuthFlowAborted("Authorization timed out waiting for redirect") from exc + + async def close(self) -> None: + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None + + +def _first(qs: dict, key: str) -> Optional[str]: + vals = qs.get(key) + if vals: + return vals[0] + return None diff --git a/src/node_wire_runtime/mcp_client/storage.py b/src/node_wire_runtime/mcp_client/storage.py index 620db58..337d75f 100644 --- a/src/node_wire_runtime/mcp_client/storage.py +++ b/src/node_wire_runtime/mcp_client/storage.py @@ -1,83 +1,83 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -"""Persistence for DCR client registrations (per authorization server issuer).""" - -from __future__ import annotations - -import json -import logging -import re -from dataclasses import asdict, dataclass -from pathlib import Path -from typing import Optional - -logger = logging.getLogger("runtime.mcp_client.storage") - -_ISSUER_SAFE = re.compile(r"[^a-zA-Z0-9._-]+") - - -@dataclass(frozen=True) -class ClientRegistration: - """RFC 7591 registration result persisted per issuer.""" - - issuer: str - client_id: str - client_secret: Optional[str] - redirect_uris: tuple[str, ...] - token_endpoint_auth_method: str - registered_at: str - - @classmethod - def from_dict(cls, data: dict) -> ClientRegistration: - return cls( - issuer=str(data["issuer"]), - client_id=str(data["client_id"]), - client_secret=data.get("client_secret"), - redirect_uris=tuple(data.get("redirect_uris") or []), - token_endpoint_auth_method=str(data.get("token_endpoint_auth_method") or "none"), - registered_at=str(data.get("registered_at") or ""), - ) - - -def default_registration_store_dir() -> Path: - return Path.home() / ".node-wire" / "mcp-oauth" / "registrations" - - -def _issuer_filename(issuer: str) -> str: - safe = _ISSUER_SAFE.sub("_", issuer.strip()).strip("_") or "issuer" - return f"{safe}.json" - - -class RegistrationStore: - """File-backed store for DCR results; one file per authorization server issuer.""" - - def __init__(self, base_dir: Optional[Path | str] = None) -> None: - self._base_dir = Path(base_dir) if base_dir else default_registration_store_dir() - self._base_dir.mkdir(parents=True, exist_ok=True) - - def _path_for(self, issuer: str) -> Path: - return self._base_dir / _issuer_filename(issuer) - - def get(self, issuer: str) -> Optional[ClientRegistration]: - path = self._path_for(issuer) - if not path.is_file(): - return None - try: - data = json.loads(path.read_text(encoding="utf-8")) - return ClientRegistration.from_dict(data) - except (json.JSONDecodeError, KeyError, TypeError) as exc: - logger.warning("Ignoring corrupt registration file %s: %s", path, exc) - return None - - def save(self, registration: ClientRegistration) -> None: - path = self._path_for(registration.issuer) - payload = asdict(registration) - path.write_text(json.dumps(payload, indent=2), encoding="utf-8") - logger.debug("Saved DCR registration for issuer", extra={"issuer": registration.issuer}) - - def delete(self, issuer: str) -> None: - path = self._path_for(issuer) - if path.is_file(): - path.unlink() +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +"""Persistence for DCR client registrations (per authorization server issuer).""" + +from __future__ import annotations + +import json +import logging +import re +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Optional + +logger = logging.getLogger("runtime.mcp_client.storage") + +_ISSUER_SAFE = re.compile(r"[^a-zA-Z0-9._-]+") + + +@dataclass(frozen=True) +class ClientRegistration: + """RFC 7591 registration result persisted per issuer.""" + + issuer: str + client_id: str + client_secret: Optional[str] + redirect_uris: tuple[str, ...] + token_endpoint_auth_method: str + registered_at: str + + @classmethod + def from_dict(cls, data: dict) -> ClientRegistration: + return cls( + issuer=str(data["issuer"]), + client_id=str(data["client_id"]), + client_secret=data.get("client_secret"), + redirect_uris=tuple(data.get("redirect_uris") or []), + token_endpoint_auth_method=str(data.get("token_endpoint_auth_method") or "none"), + registered_at=str(data.get("registered_at") or ""), + ) + + +def default_registration_store_dir() -> Path: + return Path.home() / ".node-wire" / "mcp-oauth" / "registrations" + + +def _issuer_filename(issuer: str) -> str: + safe = _ISSUER_SAFE.sub("_", issuer.strip()).strip("_") or "issuer" + return f"{safe}.json" + + +class RegistrationStore: + """File-backed store for DCR results; one file per authorization server issuer.""" + + def __init__(self, base_dir: Optional[Path | str] = None) -> None: + self._base_dir = Path(base_dir) if base_dir else default_registration_store_dir() + self._base_dir.mkdir(parents=True, exist_ok=True) + + def _path_for(self, issuer: str) -> Path: + return self._base_dir / _issuer_filename(issuer) + + def get(self, issuer: str) -> Optional[ClientRegistration]: + path = self._path_for(issuer) + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + return ClientRegistration.from_dict(data) + except (json.JSONDecodeError, KeyError, TypeError) as exc: + logger.warning("Ignoring corrupt registration file %s: %s", path, exc) + return None + + def save(self, registration: ClientRegistration) -> None: + path = self._path_for(registration.issuer) + payload = asdict(registration) + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + logger.debug("Saved DCR registration for issuer", extra={"issuer": registration.issuer}) + + def delete(self, issuer: str) -> None: + path = self._path_for(issuer) + if path.is_file(): + path.unlink() diff --git a/src/node_wire_runtime/mcp_client/token_manager.py b/src/node_wire_runtime/mcp_client/token_manager.py index e330f32..1d7166a 100644 --- a/src/node_wire_runtime/mcp_client/token_manager.py +++ b/src/node_wire_runtime/mcp_client/token_manager.py @@ -1,308 +1,308 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -"""Token lifecycle: storage, refresh, audience validation, MCP 401/403 handling.""" - -from __future__ import annotations - -import asyncio -import logging -from typing import Callable, Dict, Optional, Awaitable - -import httpx -import jwt - -from .challenges import parse_www_authenticate -from .config import McpClientConfig -from .discovery import DiscoveryResult, discover, discovery_cache_for_config -from .exceptions import ( - McpAudienceMismatch, - McpOAuthConfigurationError, - McpTokenRefreshError, -) -from .oauth_flow import AuthorizationCodeFlow, OAuthTokenSet -from .storage import ClientRegistration -from .token_storage import ( - StoredOAuthTokens, - TokenStore, - make_token_store, - stored_from_oauth_response, - token_partition_key, -) - -logger = logging.getLogger("runtime.mcp_client.token_manager") - - -class TokenManager: - """ - Manages access tokens for one MCP server + user partition. - - Proactive refresh before expiry; reactive refresh on MCP 401; re-authorization - when refresh fails. - """ - - def __init__( - self, - config: McpClientConfig, - *, - user_id: str, - token_store: Optional[TokenStore] = None, - discovery: Optional[DiscoveryResult] = None, - registration: Optional[ClientRegistration] = None, - auth_flow: Optional[AuthorizationCodeFlow] = None, - reauthorize: Optional[Callable[[], Awaitable[OAuthTokenSet]]] = None, - ) -> None: - self._config = config - self._user_id = user_id - self._store = token_store or make_token_store(config) - self._discovery = discovery - self._registration = registration - self._flow = auth_flow or AuthorizationCodeFlow( - config, - discovery=discovery, - registration=registration, - ) - self._reauthorize = reauthorize - self._refresh_lock = asyncio.Lock() - self._memory: Optional[StoredOAuthTokens] = None - - @property - def partition_key(self) -> str: - issuer = self._discovery.issuer if self._discovery else "" - return token_partition_key( - self._user_id, - self._config.canonical_server_url, - issuer, - ) - - async def ensure_discovery( - self, - *, - www_authenticate: Optional[str] = None, - ) -> DiscoveryResult: - if self._discovery is not None: - return self._discovery - cache = discovery_cache_for_config(self._config) - self._discovery = await discover( - self._config, - www_authenticate=www_authenticate, - cache=cache, - ) - self._flow._discovery = self._discovery # noqa: SLF001 — shared flow state - return self._discovery - - def load_stored(self) -> Optional[StoredOAuthTokens]: - if self._memory is not None: - return self._memory - if not self._discovery: - return None - key = token_partition_key( - self._user_id, - self._config.canonical_server_url, - self._discovery.issuer, - ) - return self._store.get(key) - - def save_tokens(self, tokens: StoredOAuthTokens) -> None: - self._memory = tokens - self._store.save(tokens) - - def discard_tokens(self) -> None: - self._memory = None - if self._discovery: - key = token_partition_key( - self._user_id, - self._config.canonical_server_url, - self._discovery.issuer, - ) - self._store.delete(key) - - def persist_oauth_token_set( - self, - token_set: OAuthTokenSet, - *, - issuer: str, - ) -> StoredOAuthTokens: - stored = stored_from_oauth_response( - user_id=self._user_id, - mcp_server_url=self._config.canonical_server_url, - issuer=issuer, - access_token=token_set.access_token, - token_type=token_set.token_type, - expires_in=token_set.expires_in, - refresh_token=token_set.refresh_token, - scope=token_set.scope, - ) - self.save_tokens(stored) - return stored - - def validate_access_token_audience(self, access_token: str) -> None: - """When token is JWT, ensure ``aud`` matches target MCP server URL.""" - if access_token.count(".") != 2: - return - try: - claims = jwt.decode( - access_token, - options={"verify_signature": False, "verify_aud": False}, - algorithms=["RS256", "RS384", "ES256", "HS256"], - ) - except jwt.PyJWTError: - return - aud = claims.get("aud") - if aud is None: - return - expected = self._config.canonical_server_url - audiences = aud if isinstance(aud, list) else [aud] - if expected not in audiences and expected.rstrip("/") not in audiences: - raise McpAudienceMismatch( - f"Token audience {audiences!r} does not match MCP server {expected!r}" - ) - - async def get_bearer_token( - self, - *, - force_refresh: bool = False, - http_client: Optional[httpx.AsyncClient] = None, - ) -> str: - discovery = await self.ensure_discovery() - stored = self.load_stored() - lead = self._config.auth.token.refresh_lead_seconds - - if stored and not force_refresh and not stored.is_expired(lead_seconds=lead): - self.validate_access_token_audience(stored.access_token) - return stored.access_token - - if stored and stored.refresh_token and not force_refresh: - try: - refreshed = await self.refresh_tokens(stored, http_client=http_client) - self.validate_access_token_audience(refreshed.access_token) - return refreshed.access_token - except McpTokenRefreshError: - self.discard_tokens() - - token_set = await self._run_reauthorize() - persisted = self.persist_oauth_token_set(token_set, issuer=discovery.issuer) - self.validate_access_token_audience(persisted.access_token) - return persisted.access_token - - async def _run_reauthorize(self) -> OAuthTokenSet: - if self._reauthorize is not None: - return await self._reauthorize() - if self._config.auth.production: - raise McpOAuthConfigurationError( - "Production mode requires a reauthorize callback; complete OAuth at " - "auth.redirect.url via start_authorization / " - "complete_authorization_with_callback_url, or inject " - "TokenManager(reauthorize=...)." - ) - return await self._flow.run_loopback_authorization(open_browser=True) - - async def refresh_tokens( - self, - stored: StoredOAuthTokens, - *, - http_client: Optional[httpx.AsyncClient] = None, - ) -> StoredOAuthTokens: - if not stored.refresh_token: - raise McpTokenRefreshError("No refresh token available") - - discovery = await self.ensure_discovery() - registration = self._flow._registration # noqa: SLF001 - if registration is None: - raise McpTokenRefreshError("Client registration not available for refresh") - - data = { - "grant_type": "refresh_token", - "refresh_token": stored.refresh_token, - "resource": self._config.canonical_server_url, - "client_id": registration.client_id, - } - - own_client = http_client is None - client = http_client or httpx.AsyncClient(timeout=30.0, verify=True) - try: - headers: Dict[str, str] = {} - from .oauth_flow import _basic_auth_header - - auth = _basic_auth_header(registration) - if auth: - headers["Authorization"] = auth - - async with self._refresh_lock: - resp = await client.post( - discovery.authorization_server.token_endpoint, - data=data, - headers=headers, - ) - if resp.status_code != 200: - body = ( - resp.json() - if resp.headers.get("content-type", "").startswith("application/json") - else {} - ) - if body.get("error") == "invalid_grant": - self.discard_tokens() - raise McpTokenRefreshError(f"Refresh failed with HTTP {resp.status_code}") - body = resp.json() - access = body.get("access_token") - if not access: - raise McpTokenRefreshError("Refresh response missing access_token") - - new_refresh = body.get("refresh_token") or stored.refresh_token - updated = stored_from_oauth_response( - user_id=stored.user_id, - mcp_server_url=stored.mcp_server_url, - issuer=stored.issuer, - access_token=str(access), - token_type=str(body.get("token_type") or stored.token_type), - expires_in=_optional_int(body.get("expires_in")), - refresh_token=new_refresh, - scope=body.get("scope") or stored.scope, - ) - self.save_tokens(updated) - return updated - except httpx.HTTPError as exc: - raise McpTokenRefreshError(f"Refresh HTTP error: {exc}") from exc - finally: - if own_client: - await client.aclose() - - async def handle_mcp_response( - self, - status_code: int, - www_authenticate: Optional[str], - ) -> str: - """ - Map MCP HTTP errors to token actions (Section 9). - - Returns action: ``retry``, ``reauthorize``, or ``forbidden``. - """ - if status_code == 403: - return "forbidden" - if status_code != 401: - return "ok" - - challenge = parse_www_authenticate(www_authenticate) - if challenge and challenge.is_insufficient_scope: - if challenge.scope: - self._config = self._config.model_copy( - update={ - "auth": self._config.auth.model_copy(update={"scopes": challenge.scope}) - } - ) - return "reauthorize" - if challenge and challenge.treat_as_unauthorized: - return "retry" - return "retry" - - -def _optional_int(value: object) -> Optional[int]: - if value is None or isinstance(value, bool): - return None - if not isinstance(value, (int, str, float)): - return None - try: - return int(value) - except (TypeError, ValueError): - return None +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +"""Token lifecycle: storage, refresh, audience validation, MCP 401/403 handling.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Callable, Dict, Optional, Awaitable + +import httpx +import jwt + +from .challenges import parse_www_authenticate +from .config import McpClientConfig +from .discovery import DiscoveryResult, discover, discovery_cache_for_config +from .exceptions import ( + McpAudienceMismatch, + McpOAuthConfigurationError, + McpTokenRefreshError, +) +from .oauth_flow import AuthorizationCodeFlow, OAuthTokenSet +from .storage import ClientRegistration +from .token_storage import ( + StoredOAuthTokens, + TokenStore, + make_token_store, + stored_from_oauth_response, + token_partition_key, +) + +logger = logging.getLogger("runtime.mcp_client.token_manager") + + +class TokenManager: + """ + Manages access tokens for one MCP server + user partition. + + Proactive refresh before expiry; reactive refresh on MCP 401; re-authorization + when refresh fails. + """ + + def __init__( + self, + config: McpClientConfig, + *, + user_id: str, + token_store: Optional[TokenStore] = None, + discovery: Optional[DiscoveryResult] = None, + registration: Optional[ClientRegistration] = None, + auth_flow: Optional[AuthorizationCodeFlow] = None, + reauthorize: Optional[Callable[[], Awaitable[OAuthTokenSet]]] = None, + ) -> None: + self._config = config + self._user_id = user_id + self._store = token_store or make_token_store(config) + self._discovery = discovery + self._registration = registration + self._flow = auth_flow or AuthorizationCodeFlow( + config, + discovery=discovery, + registration=registration, + ) + self._reauthorize = reauthorize + self._refresh_lock = asyncio.Lock() + self._memory: Optional[StoredOAuthTokens] = None + + @property + def partition_key(self) -> str: + issuer = self._discovery.issuer if self._discovery else "" + return token_partition_key( + self._user_id, + self._config.canonical_server_url, + issuer, + ) + + async def ensure_discovery( + self, + *, + www_authenticate: Optional[str] = None, + ) -> DiscoveryResult: + if self._discovery is not None: + return self._discovery + cache = discovery_cache_for_config(self._config) + self._discovery = await discover( + self._config, + www_authenticate=www_authenticate, + cache=cache, + ) + self._flow._discovery = self._discovery # noqa: SLF001 — shared flow state + return self._discovery + + def load_stored(self) -> Optional[StoredOAuthTokens]: + if self._memory is not None: + return self._memory + if not self._discovery: + return None + key = token_partition_key( + self._user_id, + self._config.canonical_server_url, + self._discovery.issuer, + ) + return self._store.get(key) + + def save_tokens(self, tokens: StoredOAuthTokens) -> None: + self._memory = tokens + self._store.save(tokens) + + def discard_tokens(self) -> None: + self._memory = None + if self._discovery: + key = token_partition_key( + self._user_id, + self._config.canonical_server_url, + self._discovery.issuer, + ) + self._store.delete(key) + + def persist_oauth_token_set( + self, + token_set: OAuthTokenSet, + *, + issuer: str, + ) -> StoredOAuthTokens: + stored = stored_from_oauth_response( + user_id=self._user_id, + mcp_server_url=self._config.canonical_server_url, + issuer=issuer, + access_token=token_set.access_token, + token_type=token_set.token_type, + expires_in=token_set.expires_in, + refresh_token=token_set.refresh_token, + scope=token_set.scope, + ) + self.save_tokens(stored) + return stored + + def validate_access_token_audience(self, access_token: str) -> None: + """When token is JWT, ensure ``aud`` matches target MCP server URL.""" + if access_token.count(".") != 2: + return + try: + claims = jwt.decode( + access_token, + options={"verify_signature": False, "verify_aud": False}, + algorithms=["RS256", "RS384", "ES256", "HS256"], + ) + except jwt.PyJWTError: + return + aud = claims.get("aud") + if aud is None: + return + expected = self._config.canonical_server_url + audiences = aud if isinstance(aud, list) else [aud] + if expected not in audiences and expected.rstrip("/") not in audiences: + raise McpAudienceMismatch( + f"Token audience {audiences!r} does not match MCP server {expected!r}" + ) + + async def get_bearer_token( + self, + *, + force_refresh: bool = False, + http_client: Optional[httpx.AsyncClient] = None, + ) -> str: + discovery = await self.ensure_discovery() + stored = self.load_stored() + lead = self._config.auth.token.refresh_lead_seconds + + if stored and not force_refresh and not stored.is_expired(lead_seconds=lead): + self.validate_access_token_audience(stored.access_token) + return stored.access_token + + if stored and stored.refresh_token and not force_refresh: + try: + refreshed = await self.refresh_tokens(stored, http_client=http_client) + self.validate_access_token_audience(refreshed.access_token) + return refreshed.access_token + except McpTokenRefreshError: + self.discard_tokens() + + token_set = await self._run_reauthorize() + persisted = self.persist_oauth_token_set(token_set, issuer=discovery.issuer) + self.validate_access_token_audience(persisted.access_token) + return persisted.access_token + + async def _run_reauthorize(self) -> OAuthTokenSet: + if self._reauthorize is not None: + return await self._reauthorize() + if self._config.auth.production: + raise McpOAuthConfigurationError( + "Production mode requires a reauthorize callback; complete OAuth at " + "auth.redirect.url via start_authorization / " + "complete_authorization_with_callback_url, or inject " + "TokenManager(reauthorize=...)." + ) + return await self._flow.run_loopback_authorization(open_browser=True) + + async def refresh_tokens( + self, + stored: StoredOAuthTokens, + *, + http_client: Optional[httpx.AsyncClient] = None, + ) -> StoredOAuthTokens: + if not stored.refresh_token: + raise McpTokenRefreshError("No refresh token available") + + discovery = await self.ensure_discovery() + registration = self._flow._registration # noqa: SLF001 + if registration is None: + raise McpTokenRefreshError("Client registration not available for refresh") + + data = { + "grant_type": "refresh_token", + "refresh_token": stored.refresh_token, + "resource": self._config.canonical_server_url, + "client_id": registration.client_id, + } + + own_client = http_client is None + client = http_client or httpx.AsyncClient(timeout=30.0, verify=True) + try: + headers: Dict[str, str] = {} + from .oauth_flow import _basic_auth_header + + auth = _basic_auth_header(registration) + if auth: + headers["Authorization"] = auth + + async with self._refresh_lock: + resp = await client.post( + discovery.authorization_server.token_endpoint, + data=data, + headers=headers, + ) + if resp.status_code != 200: + body = ( + resp.json() + if resp.headers.get("content-type", "").startswith("application/json") + else {} + ) + if body.get("error") == "invalid_grant": + self.discard_tokens() + raise McpTokenRefreshError(f"Refresh failed with HTTP {resp.status_code}") + body = resp.json() + access = body.get("access_token") + if not access: + raise McpTokenRefreshError("Refresh response missing access_token") + + new_refresh = body.get("refresh_token") or stored.refresh_token + updated = stored_from_oauth_response( + user_id=stored.user_id, + mcp_server_url=stored.mcp_server_url, + issuer=stored.issuer, + access_token=str(access), + token_type=str(body.get("token_type") or stored.token_type), + expires_in=_optional_int(body.get("expires_in")), + refresh_token=new_refresh, + scope=body.get("scope") or stored.scope, + ) + self.save_tokens(updated) + return updated + except httpx.HTTPError as exc: + raise McpTokenRefreshError(f"Refresh HTTP error: {exc}") from exc + finally: + if own_client: + await client.aclose() + + async def handle_mcp_response( + self, + status_code: int, + www_authenticate: Optional[str], + ) -> str: + """ + Map MCP HTTP errors to token actions (Section 9). + + Returns action: ``retry``, ``reauthorize``, or ``forbidden``. + """ + if status_code == 403: + return "forbidden" + if status_code != 401: + return "ok" + + challenge = parse_www_authenticate(www_authenticate) + if challenge and challenge.is_insufficient_scope: + if challenge.scope: + self._config = self._config.model_copy( + update={ + "auth": self._config.auth.model_copy(update={"scopes": challenge.scope}) + } + ) + return "reauthorize" + if challenge and challenge.treat_as_unauthorized: + return "retry" + return "retry" + + +def _optional_int(value: object) -> Optional[int]: + if value is None or isinstance(value, bool): + return None + if not isinstance(value, (int, str, float)): + return None + try: + return int(value) + except (TypeError, ValueError): + return None diff --git a/src/node_wire_runtime/mcp_client/token_storage.py b/src/node_wire_runtime/mcp_client/token_storage.py index b5f5fbb..91b51d7 100644 --- a/src/node_wire_runtime/mcp_client/token_storage.py +++ b/src/node_wire_runtime/mcp_client/token_storage.py @@ -1,287 +1,287 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -"""Encrypted OAuth token storage partitioned per user, MCP server, and issuer.""" - -from __future__ import annotations - -import json -import logging -import re -import time -from abc import ABC, abstractmethod -from dataclasses import asdict, dataclass -from pathlib import Path -from typing import Optional - -from .config import McpClientConfig, TokenStoreMode, canonicalize_mcp_server_url -from node_wire_runtime.secrets import EnvSecretProvider, SecretProvider - -logger = logging.getLogger("runtime.mcp_client.token_storage") - -_PARTITION_SAFE = re.compile(r"[^a-zA-Z0-9._-]+") -_KEYRING_SERVICE = "node-wire-mcp-oauth" - - -@dataclass -class StoredOAuthTokens: - """Persisted token set for one (user, mcp_server, issuer) partition.""" - - user_id: str - mcp_server_url: str - issuer: str - access_token: str - token_type: str - refresh_token: Optional[str] - scope: Optional[str] - expires_at: Optional[float] - updated_at: float - - @classmethod - def from_dict(cls, data: dict) -> StoredOAuthTokens: - return cls( - user_id=str(data["user_id"]), - mcp_server_url=str(data["mcp_server_url"]), - issuer=str(data["issuer"]), - access_token=str(data["access_token"]), - token_type=str(data.get("token_type") or "Bearer"), - refresh_token=data.get("refresh_token"), - scope=data.get("scope"), - expires_at=data.get("expires_at"), - updated_at=float(data.get("updated_at") or time.time()), - ) - - def is_expired(self, *, lead_seconds: int) -> bool: - if self.expires_at is None: - return False - return time.time() >= (self.expires_at - max(0, lead_seconds)) - - -def token_partition_key(user_id: str, mcp_server_url: str, issuer: str) -> str: - mcp = canonicalize_mcp_server_url(mcp_server_url) - issuer_norm = issuer.rstrip("/") - raw = f"{user_id}|{mcp}|{issuer_norm}" - safe = _PARTITION_SAFE.sub("_", raw) - return safe[:200] - - -def default_token_store_dir() -> Path: - return Path.home() / ".node-wire" / "mcp-oauth" / "tokens" - - -class TokenStore(ABC): - @abstractmethod - def get(self, partition_key: str) -> Optional[StoredOAuthTokens]: - raise NotImplementedError - - @abstractmethod - def save(self, tokens: StoredOAuthTokens) -> None: - raise NotImplementedError - - @abstractmethod - def delete(self, partition_key: str) -> None: - raise NotImplementedError - - -class InMemoryTokenStore(TokenStore): - """Test-only in-memory store.""" - - def __init__(self) -> None: - self._data: dict[str, StoredOAuthTokens] = {} - - def get(self, partition_key: str) -> Optional[StoredOAuthTokens]: - return self._data.get(partition_key) - - def save(self, tokens: StoredOAuthTokens) -> None: - key = token_partition_key(tokens.user_id, tokens.mcp_server_url, tokens.issuer) - self._data[key] = tokens - - def delete(self, partition_key: str) -> None: - self._data.pop(partition_key, None) - - -class FileEncryptedTokenStore(TokenStore): - """Fernet-encrypted JSON files (fallback when OS keychain unavailable).""" - - def __init__(self, base_dir: Optional[Path | str] = None) -> None: - self._base_dir = Path(base_dir) if base_dir else default_token_store_dir() - self._base_dir.mkdir(parents=True, exist_ok=True) - self._fernet = _load_fernet(self._base_dir) - - def _path(self, partition_key: str) -> Path: - safe = _PARTITION_SAFE.sub("_", partition_key).strip("_") or "default" - return self._base_dir / f"{safe}.token" - - def get(self, partition_key: str) -> Optional[StoredOAuthTokens]: - path = self._path(partition_key) - if not path.is_file(): - return None - try: - payload = self._fernet.decrypt(path.read_bytes()) - return StoredOAuthTokens.from_dict(json.loads(payload.decode("utf-8"))) - except Exception as exc: - logger.warning("Ignoring corrupt token file %s: %s", path, exc) - return None - - def save(self, tokens: StoredOAuthTokens) -> None: - key = token_partition_key(tokens.user_id, tokens.mcp_server_url, tokens.issuer) - path = self._path(key) - payload = json.dumps(asdict(tokens)).encode("utf-8") - path.write_bytes(self._fernet.encrypt(payload)) - - def delete(self, partition_key: str) -> None: - path = self._path(partition_key) - if path.is_file(): - path.unlink() - - -class OsKeychainTokenStore(TokenStore): - """OS keychain via optional ``keyring`` package; falls back to encrypted files.""" - - def __init__(self, *, fallback_dir: Optional[Path | str] = None) -> None: - self._fallback = FileEncryptedTokenStore(fallback_dir) - self._keyring = _try_import_keyring() - - def get(self, partition_key: str) -> Optional[StoredOAuthTokens]: - if self._keyring is None: - return self._fallback.get(partition_key) - try: - raw = self._keyring.get_password(_KEYRING_SERVICE, partition_key) - if not raw: - return None - return StoredOAuthTokens.from_dict(json.loads(raw)) - except Exception as exc: - logger.warning("Keychain read failed, using file fallback: %s", exc) - return self._fallback.get(partition_key) - - def save(self, tokens: StoredOAuthTokens) -> None: - key = token_partition_key(tokens.user_id, tokens.mcp_server_url, tokens.issuer) - payload = json.dumps(asdict(tokens)) - if self._keyring is not None: - try: - self._keyring.set_password(_KEYRING_SERVICE, key, payload) - return - except Exception as exc: - logger.warning("Keychain write failed, using file fallback: %s", exc) - self._fallback.save(tokens) - - def delete(self, partition_key: str) -> None: - if self._keyring is not None: - try: - self._keyring.delete_password(_KEYRING_SERVICE, partition_key) - except Exception: - pass - self._fallback.delete(partition_key) - - -class SecretProviderTokenStore(TokenStore): - """Store encrypted blobs in a configured secret backend (env JSON per partition key).""" - - def __init__( - self, - secret_provider: SecretProvider, - *, - key_prefix: str = "NW_MCP_OAUTH_TOKEN_", - ) -> None: - self._secrets = secret_provider - self._prefix = key_prefix - - def _secret_key(self, partition_key: str) -> str: - safe = _PARTITION_SAFE.sub("_", partition_key).strip("_").upper() - return f"{self._prefix}{safe}" - - def get(self, partition_key: str) -> Optional[StoredOAuthTokens]: - try: - raw = self._secrets.get_secret(self._secret_key(partition_key)) - except KeyError: - return None - if not raw: - return None - try: - return StoredOAuthTokens.from_dict(json.loads(raw)) - except (json.JSONDecodeError, KeyError, TypeError): - return None - - def save(self, tokens: StoredOAuthTokens) -> None: - key = token_partition_key(tokens.user_id, tokens.mcp_server_url, tokens.issuer) - # SecretProvider is read-only in runtime; persist via env only in tests. - # Production server deployments should use OsKeychainTokenStore or external vault - # integration wired by operators; this store supports EnvSecretProvider round-trip - # when NW_MCP_OAUTH_TOKEN_* vars are injected by the platform. - import os - - os.environ[self._secret_key(key)] = json.dumps(asdict(tokens)) - - def delete(self, partition_key: str) -> None: - import os - - os.environ.pop(self._secret_key(partition_key), None) - - -def make_token_store( - config: McpClientConfig, - *, - token_store_path: Optional[str] = None, - secret_provider: Optional[SecretProvider] = None, -) -> TokenStore: - mode = config.auth.token.store - if mode == TokenStoreMode.CONFIGURED_SECRET_STORE: - return SecretProviderTokenStore(secret_provider or EnvSecretProvider()) - return OsKeychainTokenStore( - fallback_dir=token_store_path or config.auth.registration_store_path, - ) - - -def _try_import_keyring(): - try: - import keyring # type: ignore[import-untyped] - - return keyring - except ImportError: - return None - - -def _load_fernet(base_dir: Path): - try: - from cryptography.fernet import Fernet - except ImportError as exc: - raise RuntimeError( - "cryptography package required for encrypted token storage; " - "install PyJWT[crypto] or cryptography" - ) from exc - - key_path = base_dir / ".fernet.key" - if key_path.is_file(): - key = key_path.read_bytes() - else: - key = Fernet.generate_key() - key_path.write_bytes(key) - return Fernet(key) - - -def stored_from_oauth_response( - *, - user_id: str, - mcp_server_url: str, - issuer: str, - access_token: str, - token_type: str, - expires_in: Optional[int], - refresh_token: Optional[str], - scope: Optional[str], -) -> StoredOAuthTokens: - expires_at: Optional[float] = None - if expires_in is not None and expires_in > 0: - expires_at = time.time() + float(expires_in) - return StoredOAuthTokens( - user_id=user_id, - mcp_server_url=canonicalize_mcp_server_url(mcp_server_url), - issuer=issuer.rstrip("/"), - access_token=access_token, - token_type=token_type, - refresh_token=refresh_token, - scope=scope, - expires_at=expires_at, - updated_at=time.time(), - ) +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +"""Encrypted OAuth token storage partitioned per user, MCP server, and issuer.""" + +from __future__ import annotations + +import json +import logging +import re +import time +from abc import ABC, abstractmethod +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Optional + +from .config import McpClientConfig, TokenStoreMode, canonicalize_mcp_server_url +from node_wire_runtime.secrets import EnvSecretProvider, SecretProvider + +logger = logging.getLogger("runtime.mcp_client.token_storage") + +_PARTITION_SAFE = re.compile(r"[^a-zA-Z0-9._-]+") +_KEYRING_SERVICE = "node-wire-mcp-oauth" + + +@dataclass +class StoredOAuthTokens: + """Persisted token set for one (user, mcp_server, issuer) partition.""" + + user_id: str + mcp_server_url: str + issuer: str + access_token: str + token_type: str + refresh_token: Optional[str] + scope: Optional[str] + expires_at: Optional[float] + updated_at: float + + @classmethod + def from_dict(cls, data: dict) -> StoredOAuthTokens: + return cls( + user_id=str(data["user_id"]), + mcp_server_url=str(data["mcp_server_url"]), + issuer=str(data["issuer"]), + access_token=str(data["access_token"]), + token_type=str(data.get("token_type") or "Bearer"), + refresh_token=data.get("refresh_token"), + scope=data.get("scope"), + expires_at=data.get("expires_at"), + updated_at=float(data.get("updated_at") or time.time()), + ) + + def is_expired(self, *, lead_seconds: int) -> bool: + if self.expires_at is None: + return False + return time.time() >= (self.expires_at - max(0, lead_seconds)) + + +def token_partition_key(user_id: str, mcp_server_url: str, issuer: str) -> str: + mcp = canonicalize_mcp_server_url(mcp_server_url) + issuer_norm = issuer.rstrip("/") + raw = f"{user_id}|{mcp}|{issuer_norm}" + safe = _PARTITION_SAFE.sub("_", raw) + return safe[:200] + + +def default_token_store_dir() -> Path: + return Path.home() / ".node-wire" / "mcp-oauth" / "tokens" + + +class TokenStore(ABC): + @abstractmethod + def get(self, partition_key: str) -> Optional[StoredOAuthTokens]: + raise NotImplementedError + + @abstractmethod + def save(self, tokens: StoredOAuthTokens) -> None: + raise NotImplementedError + + @abstractmethod + def delete(self, partition_key: str) -> None: + raise NotImplementedError + + +class InMemoryTokenStore(TokenStore): + """Test-only in-memory store.""" + + def __init__(self) -> None: + self._data: dict[str, StoredOAuthTokens] = {} + + def get(self, partition_key: str) -> Optional[StoredOAuthTokens]: + return self._data.get(partition_key) + + def save(self, tokens: StoredOAuthTokens) -> None: + key = token_partition_key(tokens.user_id, tokens.mcp_server_url, tokens.issuer) + self._data[key] = tokens + + def delete(self, partition_key: str) -> None: + self._data.pop(partition_key, None) + + +class FileEncryptedTokenStore(TokenStore): + """Fernet-encrypted JSON files (fallback when OS keychain unavailable).""" + + def __init__(self, base_dir: Optional[Path | str] = None) -> None: + self._base_dir = Path(base_dir) if base_dir else default_token_store_dir() + self._base_dir.mkdir(parents=True, exist_ok=True) + self._fernet = _load_fernet(self._base_dir) + + def _path(self, partition_key: str) -> Path: + safe = _PARTITION_SAFE.sub("_", partition_key).strip("_") or "default" + return self._base_dir / f"{safe}.token" + + def get(self, partition_key: str) -> Optional[StoredOAuthTokens]: + path = self._path(partition_key) + if not path.is_file(): + return None + try: + payload = self._fernet.decrypt(path.read_bytes()) + return StoredOAuthTokens.from_dict(json.loads(payload.decode("utf-8"))) + except Exception as exc: + logger.warning("Ignoring corrupt token file %s: %s", path, exc) + return None + + def save(self, tokens: StoredOAuthTokens) -> None: + key = token_partition_key(tokens.user_id, tokens.mcp_server_url, tokens.issuer) + path = self._path(key) + payload = json.dumps(asdict(tokens)).encode("utf-8") + path.write_bytes(self._fernet.encrypt(payload)) + + def delete(self, partition_key: str) -> None: + path = self._path(partition_key) + if path.is_file(): + path.unlink() + + +class OsKeychainTokenStore(TokenStore): + """OS keychain via optional ``keyring`` package; falls back to encrypted files.""" + + def __init__(self, *, fallback_dir: Optional[Path | str] = None) -> None: + self._fallback = FileEncryptedTokenStore(fallback_dir) + self._keyring = _try_import_keyring() + + def get(self, partition_key: str) -> Optional[StoredOAuthTokens]: + if self._keyring is None: + return self._fallback.get(partition_key) + try: + raw = self._keyring.get_password(_KEYRING_SERVICE, partition_key) + if not raw: + return None + return StoredOAuthTokens.from_dict(json.loads(raw)) + except Exception as exc: + logger.warning("Keychain read failed, using file fallback: %s", exc) + return self._fallback.get(partition_key) + + def save(self, tokens: StoredOAuthTokens) -> None: + key = token_partition_key(tokens.user_id, tokens.mcp_server_url, tokens.issuer) + payload = json.dumps(asdict(tokens)) + if self._keyring is not None: + try: + self._keyring.set_password(_KEYRING_SERVICE, key, payload) + return + except Exception as exc: + logger.warning("Keychain write failed, using file fallback: %s", exc) + self._fallback.save(tokens) + + def delete(self, partition_key: str) -> None: + if self._keyring is not None: + try: + self._keyring.delete_password(_KEYRING_SERVICE, partition_key) + except Exception: + pass + self._fallback.delete(partition_key) + + +class SecretProviderTokenStore(TokenStore): + """Store encrypted blobs in a configured secret backend (env JSON per partition key).""" + + def __init__( + self, + secret_provider: SecretProvider, + *, + key_prefix: str = "NW_MCP_OAUTH_TOKEN_", + ) -> None: + self._secrets = secret_provider + self._prefix = key_prefix + + def _secret_key(self, partition_key: str) -> str: + safe = _PARTITION_SAFE.sub("_", partition_key).strip("_").upper() + return f"{self._prefix}{safe}" + + def get(self, partition_key: str) -> Optional[StoredOAuthTokens]: + try: + raw = self._secrets.get_secret(self._secret_key(partition_key)) + except KeyError: + return None + if not raw: + return None + try: + return StoredOAuthTokens.from_dict(json.loads(raw)) + except (json.JSONDecodeError, KeyError, TypeError): + return None + + def save(self, tokens: StoredOAuthTokens) -> None: + key = token_partition_key(tokens.user_id, tokens.mcp_server_url, tokens.issuer) + # SecretProvider is read-only in runtime; persist via env only in tests. + # Production server deployments should use OsKeychainTokenStore or external vault + # integration wired by operators; this store supports EnvSecretProvider round-trip + # when NW_MCP_OAUTH_TOKEN_* vars are injected by the platform. + import os + + os.environ[self._secret_key(key)] = json.dumps(asdict(tokens)) + + def delete(self, partition_key: str) -> None: + import os + + os.environ.pop(self._secret_key(partition_key), None) + + +def make_token_store( + config: McpClientConfig, + *, + token_store_path: Optional[str] = None, + secret_provider: Optional[SecretProvider] = None, +) -> TokenStore: + mode = config.auth.token.store + if mode == TokenStoreMode.CONFIGURED_SECRET_STORE: + return SecretProviderTokenStore(secret_provider or EnvSecretProvider()) + return OsKeychainTokenStore( + fallback_dir=token_store_path or config.auth.registration_store_path, + ) + + +def _try_import_keyring(): + try: + import keyring # type: ignore[import-untyped] + + return keyring + except ImportError: + return None + + +def _load_fernet(base_dir: Path): + try: + from cryptography.fernet import Fernet + except ImportError as exc: + raise RuntimeError( + "cryptography package required for encrypted token storage; " + "install PyJWT[crypto] or cryptography" + ) from exc + + key_path = base_dir / ".fernet.key" + if key_path.is_file(): + key = key_path.read_bytes() + else: + key = Fernet.generate_key() + key_path.write_bytes(key) + return Fernet(key) + + +def stored_from_oauth_response( + *, + user_id: str, + mcp_server_url: str, + issuer: str, + access_token: str, + token_type: str, + expires_in: Optional[int], + refresh_token: Optional[str], + scope: Optional[str], +) -> StoredOAuthTokens: + expires_at: Optional[float] = None + if expires_in is not None and expires_in > 0: + expires_at = time.time() + float(expires_in) + return StoredOAuthTokens( + user_id=user_id, + mcp_server_url=canonicalize_mcp_server_url(mcp_server_url), + issuer=issuer.rstrip("/"), + access_token=access_token, + token_type=token_type, + refresh_token=refresh_token, + scope=scope, + expires_at=expires_at, + updated_at=time.time(), + ) diff --git a/src/node_wire_runtime/models.py b/src/node_wire_runtime/models.py index 9daf43c..7f9e886 100644 --- a/src/node_wire_runtime/models.py +++ b/src/node_wire_runtime/models.py @@ -1,31 +1,31 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -from __future__ import annotations - -from typing import Any, Optional - -from pydantic import BaseModel -from enum import Enum - - -class ErrorCategory(str, Enum): - RETRYABLE = "RETRYABLE" - BUSINESS = "BUSINESS" - AUTH = "AUTH" - FATAL = "FATAL" - - -class ConnectorResponse(BaseModel): - """Standardized response model returned by all connectors.""" - - success: bool - data: Optional[Any] = None - error_code: Optional[str] = None - error_category: Optional[ErrorCategory] = None - message: Optional[str] = None - trace_id: str - details: Optional[Any] = ( - None # e.g. validation errors: [{"loc": ["url"], "msg": "...", "type": "..."}] - ) +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +from typing import Any, Optional + +from pydantic import BaseModel +from enum import Enum + + +class ErrorCategory(str, Enum): + RETRYABLE = "RETRYABLE" + BUSINESS = "BUSINESS" + AUTH = "AUTH" + FATAL = "FATAL" + + +class ConnectorResponse(BaseModel): + """Standardized response model returned by all connectors.""" + + success: bool + data: Optional[Any] = None + error_code: Optional[str] = None + error_category: Optional[ErrorCategory] = None + message: Optional[str] = None + trace_id: str + details: Optional[Any] = ( + None # e.g. validation errors: [{"loc": ["url"], "msg": "...", "type": "..."}] + ) diff --git a/src/node_wire_runtime/policies/mcp_scope_policy.py b/src/node_wire_runtime/policies/mcp_scope_policy.py index acf7370..5016ed9 100644 --- a/src/node_wire_runtime/policies/mcp_scope_policy.py +++ b/src/node_wire_runtime/policies/mcp_scope_policy.py @@ -1,185 +1,189 @@ -from __future__ import annotations - -import json -import logging -import os -from pathlib import Path -from typing import Mapping, Optional - -from dotenv import load_dotenv - -from node_wire_runtime.policy import PolicyContext, PolicyDenied, PolicyHook - -logger = logging.getLogger("runtime.policy.scope") - -# Public for tests and MCP tool listing (must match hook behavior). -DEFAULT_SCOPE_MODE_ALLOW = "allow" -DEFAULT_SCOPE_MODE_DENY = "deny" - -_warned_implicit_scope_default = False - - -def _truthy_default_mode(val: str) -> str: - v = val.strip().lower() - if v in ("deny", "default-deny", "closed"): - return DEFAULT_SCOPE_MODE_DENY - return DEFAULT_SCOPE_MODE_ALLOW - - -def load_scope_policy_default_from_env() -> str: - """Return ``allow`` or ``deny`` from ``NW_MCP_SCOPE_POLICY_DEFAULT`` (default: deny).""" - global _warned_implicit_scope_default - raw = os.environ.get("NW_MCP_SCOPE_POLICY_DEFAULT") - if not raw or not str(raw).strip(): - if not _warned_implicit_scope_default: - logger.warning( - "NW_MCP_SCOPE_POLICY_DEFAULT is unset; using code default 'deny'. " - "Set NW_MCP_SCOPE_POLICY_DEFAULT explicitly and configure " - "NW_*_API_KEY_SCOPES (or JWT scopes) for each transport." - ) - _warned_implicit_scope_default = True - return DEFAULT_SCOPE_MODE_DENY - return _truthy_default_mode(str(raw)) - - -def resolve_required_scope_for_action( - *, - connector_id: str, - action: str, - action_scope_map: Mapping[str, str], - default_mode: str, -) -> Optional[str]: - """ - Determine the scope string required for this action. - - - **allow**: only enforce when ``NW_MCP_ACTION_SCOPE_MAP_JSON`` has - an entry for ``connector_id.action``. - - **deny**: require either that explicit map entry or the conventional - fallback ``mcp:.``. - """ - action_key = f"{connector_id}.{action}" - explicit = action_scope_map.get(action_key) - if explicit: - return explicit - if default_mode == DEFAULT_SCOPE_MODE_DENY: - return f"mcp:{connector_id}.{action}" - return None - - -def action_allowed_for_identity_scopes( - *, - connector_id: str, - action: str, - principal: Optional[str], - tenant_id: Optional[str], - scopes: Optional[tuple[str, ...]], - action_scope_map: Mapping[str, str], - default_mode: str, -) -> bool: - """ - Same authorization decision as :class:`ScopePolicyHook` / ``tools/list`` filtering. - - Returns True if the action should be visible or executable for this caller. - """ - required = resolve_required_scope_for_action( - connector_id=connector_id, - action=action, - action_scope_map=action_scope_map, - default_mode=default_mode, - ) - scope_tuple = tuple(scopes or ()) - if required and not principal and not scope_tuple: - logger.info( - "Scope policy denied due to missing caller identity", - extra={ - "action_key": f"{connector_id}.{action}", - "required_scope": required, - }, - ) - return False - if not required: - return True - scope_set = set(scope_tuple) - return required in scope_set or "*" in scope_set - - -class ScopePolicyHook(PolicyHook): - def __init__( - self, - action_scope_map: Mapping[str, str], - *, - default_mode: str = DEFAULT_SCOPE_MODE_DENY, - ) -> None: - self._map = dict(action_scope_map) - self._default_mode = ( - default_mode - if default_mode in (DEFAULT_SCOPE_MODE_ALLOW, DEFAULT_SCOPE_MODE_DENY) - else DEFAULT_SCOPE_MODE_DENY - ) - - def check(self, context: PolicyContext) -> None: - action_key = f"{context.connector_id}.{context.action}" - required = resolve_required_scope_for_action( - connector_id=context.connector_id, - action=context.action, - action_scope_map=self._map, - default_mode=self._default_mode, - ) - scopes = tuple(context.scopes or ()) - if required and not context.principal and not scopes: - logger.info( - "Scope policy denied due to missing caller identity", - extra={ - "action_key": action_key, - "required_scope": required, - }, - ) - raise PolicyDenied(f"Missing required scope: {required}") - logger.info( - "Scope policy evaluating action", - extra={ - "action_key": action_key, - "required_scope": required or "", - "principal": context.principal or "", - "tenant_id": context.tenant_id or "", - "scopes": list(scopes), - }, - ) - if not required: - return - scope_set = set(scopes) - if required in scope_set or "*" in scope_set: - return - raise PolicyDenied(f"Missing required scope: {required}") - - -def load_scope_map_from_env() -> dict[str, str]: - raw = os.environ.get("NW_MCP_ACTION_SCOPE_MAP_JSON") - if not raw: - # Mirror MCP auth bootstrap behavior: recover config from project .env - # when launch paths inherit incomplete shell env. Use override=False so - # explicitly set variables (e.g. pytest conftest, production injection) are not - # stomped by repo .env — same as playground/scenarios load_dotenv(). - if os.environ.get("NW_REST_LOAD_DOTENV", "true").lower() not in ("0", "false", "no"): - repo_root_env = Path(__file__).resolve().parents[3] / ".env" - load_dotenv(override=False) - load_dotenv(repo_root_env, override=False) - raw = os.environ.get("NW_MCP_ACTION_SCOPE_MAP_JSON") - if not raw: - logger.info("Scope policy map not configured (env empty)") - return {} - parsed = json.loads(raw) - if not isinstance(parsed, dict): - raise ValueError("NW_MCP_ACTION_SCOPE_MAP_JSON must be a JSON object.") - out: dict[str, str] = {} - for key, value in parsed.items(): - if not isinstance(key, str) or not isinstance(value, str): - raise ValueError( - "NW_MCP_ACTION_SCOPE_MAP_JSON must map string action keys to string scopes." - ) - out[key] = value - logger.info( - "Scope policy map loaded", - extra={"entries": len(out), "action_keys": sorted(out.keys())}, - ) - return out +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path +from typing import Mapping, Optional + +from dotenv import load_dotenv + +from node_wire_runtime.policy import PolicyContext, PolicyDenied, PolicyHook + +logger = logging.getLogger("runtime.policy.scope") + +# Public for tests and MCP tool listing (must match hook behavior). +DEFAULT_SCOPE_MODE_ALLOW = "allow" +DEFAULT_SCOPE_MODE_DENY = "deny" + +_warned_implicit_scope_default = False + + +def _truthy_default_mode(val: str) -> str: + v = val.strip().lower() + if v in ("deny", "default-deny", "closed"): + return DEFAULT_SCOPE_MODE_DENY + return DEFAULT_SCOPE_MODE_ALLOW + + +def load_scope_policy_default_from_env() -> str: + """Return ``allow`` or ``deny`` from ``NW_MCP_SCOPE_POLICY_DEFAULT`` (default: deny).""" + global _warned_implicit_scope_default + raw = os.environ.get("NW_MCP_SCOPE_POLICY_DEFAULT") + if not raw or not str(raw).strip(): + if not _warned_implicit_scope_default: + logger.warning( + "NW_MCP_SCOPE_POLICY_DEFAULT is unset; using code default 'deny'. " + "Set NW_MCP_SCOPE_POLICY_DEFAULT explicitly and configure " + "NW_*_API_KEY_SCOPES (or JWT scopes) for each transport." + ) + _warned_implicit_scope_default = True + return DEFAULT_SCOPE_MODE_DENY + return _truthy_default_mode(str(raw)) + + +def resolve_required_scope_for_action( + *, + connector_id: str, + action: str, + action_scope_map: Mapping[str, str], + default_mode: str, +) -> Optional[str]: + """ + Determine the scope string required for this action. + + - **allow**: only enforce when ``NW_MCP_ACTION_SCOPE_MAP_JSON`` has + an entry for ``connector_id.action``. + - **deny**: require either that explicit map entry or the conventional + fallback ``mcp:.``. + """ + action_key = f"{connector_id}.{action}" + explicit = action_scope_map.get(action_key) + if explicit: + return explicit + if default_mode == DEFAULT_SCOPE_MODE_DENY: + return f"mcp:{connector_id}.{action}" + return None + + +def action_allowed_for_identity_scopes( + *, + connector_id: str, + action: str, + principal: Optional[str], + tenant_id: Optional[str], + scopes: Optional[tuple[str, ...]], + action_scope_map: Mapping[str, str], + default_mode: str, +) -> bool: + """ + Same authorization decision as :class:`ScopePolicyHook` / ``tools/list`` filtering. + + Returns True if the action should be visible or executable for this caller. + """ + required = resolve_required_scope_for_action( + connector_id=connector_id, + action=action, + action_scope_map=action_scope_map, + default_mode=default_mode, + ) + scope_tuple = tuple(scopes or ()) + if required and not principal and not scope_tuple: + logger.info( + "Scope policy denied due to missing caller identity", + extra={ + "action_key": f"{connector_id}.{action}", + "required_scope": required, + }, + ) + return False + if not required: + return True + scope_set = set(scope_tuple) + return required in scope_set or "*" in scope_set + + +class ScopePolicyHook(PolicyHook): + def __init__( + self, + action_scope_map: Mapping[str, str], + *, + default_mode: str = DEFAULT_SCOPE_MODE_DENY, + ) -> None: + self._map = dict(action_scope_map) + self._default_mode = ( + default_mode + if default_mode in (DEFAULT_SCOPE_MODE_ALLOW, DEFAULT_SCOPE_MODE_DENY) + else DEFAULT_SCOPE_MODE_DENY + ) + + def check(self, context: PolicyContext) -> None: + action_key = f"{context.connector_id}.{context.action}" + required = resolve_required_scope_for_action( + connector_id=context.connector_id, + action=context.action, + action_scope_map=self._map, + default_mode=self._default_mode, + ) + scopes = tuple(context.scopes or ()) + if required and not context.principal and not scopes: + logger.info( + "Scope policy denied due to missing caller identity", + extra={ + "action_key": action_key, + "required_scope": required, + }, + ) + raise PolicyDenied(f"Missing required scope: {required}") + logger.info( + "Scope policy evaluating action", + extra={ + "action_key": action_key, + "required_scope": required or "", + "principal": context.principal or "", + "tenant_id": context.tenant_id or "", + "scopes": list(scopes), + }, + ) + if not required: + return + scope_set = set(scopes) + if required in scope_set or "*" in scope_set: + return + raise PolicyDenied(f"Missing required scope: {required}") + + +def load_scope_map_from_env() -> dict[str, str]: + raw = os.environ.get("NW_MCP_ACTION_SCOPE_MAP_JSON") + if not raw: + # Mirror MCP auth bootstrap behavior: recover config from project .env + # when launch paths inherit incomplete shell env. Use override=False so + # explicitly set variables (e.g. pytest conftest, production injection) are not + # stomped by repo .env — same as playground/scenarios load_dotenv(). + if os.environ.get("NW_REST_LOAD_DOTENV", "true").lower() not in ("0", "false", "no"): + repo_root_env = Path(__file__).resolve().parents[3] / ".env" + load_dotenv(override=False) + load_dotenv(repo_root_env, override=False) + raw = os.environ.get("NW_MCP_ACTION_SCOPE_MAP_JSON") + if not raw: + logger.info("Scope policy map not configured (env empty)") + return {} + parsed = json.loads(raw) + if not isinstance(parsed, dict): + raise ValueError("NW_MCP_ACTION_SCOPE_MAP_JSON must be a JSON object.") + out: dict[str, str] = {} + for key, value in parsed.items(): + if not isinstance(key, str) or not isinstance(value, str): + raise ValueError( + "NW_MCP_ACTION_SCOPE_MAP_JSON must map string action keys to string scopes." + ) + out[key] = value + logger.info( + "Scope policy map loaded", + extra={"entries": len(out), "action_keys": sorted(out.keys())}, + ) + return out diff --git a/src/node_wire_runtime/policy.py b/src/node_wire_runtime/policy.py index 3904ce7..ab45f7d 100644 --- a/src/node_wire_runtime/policy.py +++ b/src/node_wire_runtime/policy.py @@ -1,41 +1,41 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -from __future__ import annotations - -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import Any, Mapping, Optional - - -@dataclass -class PolicyContext: - connector_id: str - action: str - input_payload: Mapping[str, Any] - principal: Optional[str] = None - tenant_id: Optional[str] = None - scopes: Optional[tuple[str, ...]] = None - - -class PolicyDenied(Exception): - """Raised when a policy hook denies execution.""" - - -class PolicyHook(ABC): - """ - Pre-execution policy hook. - - Implementations can integrate RBAC, OPA, or PII/PHI checks. For the POC we - will provide a simple allow-all implementation in Layer C and keep this - contract stable. - """ - - @abstractmethod - def check(self, context: PolicyContext) -> None: - """ - Perform policy evaluation. - Raise PolicyDenied with a human-readable message when execution is not allowed. - """ - raise NotImplementedError +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Mapping, Optional + + +@dataclass +class PolicyContext: + connector_id: str + action: str + input_payload: Mapping[str, Any] + principal: Optional[str] = None + tenant_id: Optional[str] = None + scopes: Optional[tuple[str, ...]] = None + + +class PolicyDenied(Exception): + """Raised when a policy hook denies execution.""" + + +class PolicyHook(ABC): + """ + Pre-execution policy hook. + + Implementations can integrate RBAC, OPA, or PII/PHI checks. For the POC we + will provide a simple allow-all implementation in Layer C and keep this + contract stable. + """ + + @abstractmethod + def check(self, context: PolicyContext) -> None: + """ + Perform policy evaluation. + Raise PolicyDenied with a human-readable message when execution is not allowed. + """ + raise NotImplementedError diff --git a/src/node_wire_runtime/rate_limit.py b/src/node_wire_runtime/rate_limit.py index 4caf487..d338fa8 100644 --- a/src/node_wire_runtime/rate_limit.py +++ b/src/node_wire_runtime/rate_limit.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """ In-memory Token Bucket rate limiter to prevent DoS across bindings. Configuration via environment variables: diff --git a/src/node_wire_runtime/streaming.py b/src/node_wire_runtime/streaming.py index e66079d..1e3b16b 100644 --- a/src/node_wire_runtime/streaming.py +++ b/src/node_wire_runtime/streaming.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# import os import time import logging diff --git a/src/node_wire_salesforce/__init__.py b/src/node_wire_salesforce/__init__.py index b2c3109..32a6202 100644 --- a/src/node_wire_salesforce/__init__.py +++ b/src/node_wire_salesforce/__init__.py @@ -1 +1,6 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# + # Connector subpackage: salesforce diff --git a/src/node_wire_salesforce/logic.py b/src/node_wire_salesforce/logic.py index 1b89d94..63ec172 100644 --- a/src/node_wire_salesforce/logic.py +++ b/src/node_wire_salesforce/logic.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from __future__ import annotations import json diff --git a/src/node_wire_salesforce/registration.py b/src/node_wire_salesforce/registration.py index 86de1be..e5c7233 100644 --- a/src/node_wire_salesforce/registration.py +++ b/src/node_wire_salesforce/registration.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from __future__ import annotations # Salesforce registration module. diff --git a/src/node_wire_salesforce/schema.py b/src/node_wire_salesforce/schema.py index 6186f8f..054e620 100644 --- a/src/node_wire_salesforce/schema.py +++ b/src/node_wire_salesforce/schema.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from typing import Any, Dict, List, Literal, Optional, Union from pydantic import BaseModel, Field, field_validator, model_validator, ConfigDict import re diff --git a/src/node_wire_slack/README.md b/src/node_wire_slack/README.md index 4f1fb8e..8aba9a4 100644 --- a/src/node_wire_slack/README.md +++ b/src/node_wire_slack/README.md @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + # Slack Connector — Technical Documentation > **Platform:** Node Wire diff --git a/src/node_wire_slack/__init__.py b/src/node_wire_slack/__init__.py index 74ae7ef..79de399 100644 --- a/src/node_wire_slack/__init__.py +++ b/src/node_wire_slack/__init__.py @@ -1 +1,6 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# + # Connector subpackage: slack diff --git a/src/node_wire_slack/exceptions.py b/src/node_wire_slack/exceptions.py index fa379fc..1db47c1 100644 --- a/src/node_wire_slack/exceptions.py +++ b/src/node_wire_slack/exceptions.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """ Domain exception hierarchy for the Slack connector. diff --git a/src/node_wire_slack/logic.py b/src/node_wire_slack/logic.py index 521e95a..5cd5b9c 100644 --- a/src/node_wire_slack/logic.py +++ b/src/node_wire_slack/logic.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """ Slack connector for Node-Wire. diff --git a/src/node_wire_slack/registration.py b/src/node_wire_slack/registration.py index 31d53fd..4b47c8f 100644 --- a/src/node_wire_slack/registration.py +++ b/src/node_wire_slack/registration.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """ ErrorMapper registrations for the Slack connector. diff --git a/src/node_wire_slack/schema.py b/src/node_wire_slack/schema.py index 54079d5..a544483 100644 --- a/src/node_wire_slack/schema.py +++ b/src/node_wire_slack/schema.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """ Pydantic v2 input/output models for the Slack connector. diff --git a/src/node_wire_smtp/__init__.py b/src/node_wire_smtp/__init__.py index 68d3669..3564f83 100644 --- a/src/node_wire_smtp/__init__.py +++ b/src/node_wire_smtp/__init__.py @@ -1,6 +1,6 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# - -# Connector subpackage: smtp +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# + +# Connector subpackage: smtp diff --git a/src/node_wire_smtp/schema.py b/src/node_wire_smtp/schema.py index 665b3c9..625eafe 100644 --- a/src/node_wire_smtp/schema.py +++ b/src/node_wire_smtp/schema.py @@ -1,99 +1,99 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -from __future__ import annotations - -import os -import re -from typing import Any, List, Literal, Optional, Union - -from pydantic import BaseModel, EmailStr, field_validator, model_validator - -_FORBIDDEN_RELAY_KEYS = frozenset({"host", "port", "use_tls"}) -_HEADER_UNSAFE_RE = re.compile(r"[\x00-\x1f\x7f]") - - -def _reject_unsafe_header_value(value: str, field_name: str) -> str: - if _HEADER_UNSAFE_RE.search(value): - raise ValueError(f"{field_name} must not contain control characters or newlines") - return value - - -def _strip_env(s: str) -> str: - return s.strip(" '\"") - - -def _extract_email(value: str) -> str: - """Pydantic EmailStr does not accept 'Name '.""" - match = re.search(r"<(.+?)>", value) - return (match.group(1) if match else value).strip() - - -class SmtpSendInput(BaseModel): - """ - Send an email via SMTP. - - Only ``to``, ``subject``, and ``body`` are required. SMTP connection settings - (``SMTP_HOST``, ``SMTP_PORT``, ``SMTP_USE_TLS``) are configured server-side - only — they cannot be supplied in the request payload. - - Credentials (username and password) are **not** part of this schema. - They are managed entirely by the :class:`AuthProvider` injected into the - connector by the factory, keeping secrets out of the request payload. - """ - - action: Literal["send_email"] = "send_email" - from_email: Optional[EmailStr] = None - to: Union[str, List[EmailStr]] - subject: str - body: str - - @field_validator("subject") - @classmethod - def _validate_subject(cls, value: str) -> str: - return _reject_unsafe_header_value(value, "subject") - - @model_validator(mode="before") - @classmethod - def _reject_relay_fields_and_normalize(cls, values: Any) -> Any: - if not isinstance(values, dict): - return values - - for key in _FORBIDDEN_RELAY_KEYS: - if key in values: - values.pop(key, None) - - if "from" in values and not values.get("from_email"): - values["from_email"] = values.pop("from") - - fe = values.get("from_email") - if fe is None or not str(fe).strip(): - values["from_email"] = _strip_env( - os.environ.get("FROM_EMAIL") - or os.environ.get("SMTP_USERNAME") - or "noreply@node-wire.local" - ) - else: - values["from_email"] = _extract_email(_strip_env(str(fe))) - - sender = str(values["from_email"]) - if not sender or "@" not in sender or "system_default" in sender: - values["from_email"] = _strip_env( - os.environ.get("FROM_EMAIL") - or os.environ.get("SMTP_USERNAME") - or "noreply@node-wire.local" - ) - - raw_to = values.get("to") - if isinstance(raw_to, str): - values["to"] = [_extract_email(raw_to)] - elif isinstance(raw_to, list): - values["to"] = [_extract_email(str(x)) for x in raw_to] - - return values - - -class SmtpSendOutput(BaseModel): - sent: bool - message_id: Optional[str] = None +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +import os +import re +from typing import Any, List, Literal, Optional, Union + +from pydantic import BaseModel, EmailStr, field_validator, model_validator + +_FORBIDDEN_RELAY_KEYS = frozenset({"host", "port", "use_tls"}) +_HEADER_UNSAFE_RE = re.compile(r"[\x00-\x1f\x7f]") + + +def _reject_unsafe_header_value(value: str, field_name: str) -> str: + if _HEADER_UNSAFE_RE.search(value): + raise ValueError(f"{field_name} must not contain control characters or newlines") + return value + + +def _strip_env(s: str) -> str: + return s.strip(" '\"") + + +def _extract_email(value: str) -> str: + """Pydantic EmailStr does not accept 'Name '.""" + match = re.search(r"<(.+?)>", value) + return (match.group(1) if match else value).strip() + + +class SmtpSendInput(BaseModel): + """ + Send an email via SMTP. + + Only ``to``, ``subject``, and ``body`` are required. SMTP connection settings + (``SMTP_HOST``, ``SMTP_PORT``, ``SMTP_USE_TLS``) are configured server-side + only — they cannot be supplied in the request payload. + + Credentials (username and password) are **not** part of this schema. + They are managed entirely by the :class:`AuthProvider` injected into the + connector by the factory, keeping secrets out of the request payload. + """ + + action: Literal["send_email"] = "send_email" + from_email: Optional[EmailStr] = None + to: Union[str, List[EmailStr]] + subject: str + body: str + + @field_validator("subject") + @classmethod + def _validate_subject(cls, value: str) -> str: + return _reject_unsafe_header_value(value, "subject") + + @model_validator(mode="before") + @classmethod + def _reject_relay_fields_and_normalize(cls, values: Any) -> Any: + if not isinstance(values, dict): + return values + + for key in _FORBIDDEN_RELAY_KEYS: + if key in values: + values.pop(key, None) + + if "from" in values and not values.get("from_email"): + values["from_email"] = values.pop("from") + + fe = values.get("from_email") + if fe is None or not str(fe).strip(): + values["from_email"] = _strip_env( + os.environ.get("FROM_EMAIL") + or os.environ.get("SMTP_USERNAME") + or "noreply@node-wire.local" + ) + else: + values["from_email"] = _extract_email(_strip_env(str(fe))) + + sender = str(values["from_email"]) + if not sender or "@" not in sender or "system_default" in sender: + values["from_email"] = _strip_env( + os.environ.get("FROM_EMAIL") + or os.environ.get("SMTP_USERNAME") + or "noreply@node-wire.local" + ) + + raw_to = values.get("to") + if isinstance(raw_to, str): + values["to"] = [_extract_email(raw_to)] + elif isinstance(raw_to, list): + values["to"] = [_extract_email(str(x)) for x in raw_to] + + return values + + +class SmtpSendOutput(BaseModel): + sent: bool + message_id: Optional[str] = None diff --git a/src/node_wire_stripe/README.md b/src/node_wire_stripe/README.md index 45a2da4..0e3eebf 100644 --- a/src/node_wire_stripe/README.md +++ b/src/node_wire_stripe/README.md @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + # Node Wire Connector — Stripe The Stripe connector provides a reliable, async adapter for processing payments and managing subscriptions using the Stripe Python SDK. It follows the Node Wire platform contract: consistent error handling, resilience (retries/circuit breaking), and standardized telemetry. diff --git a/src/node_wire_stripe/__init__.py b/src/node_wire_stripe/__init__.py index baa5905..7aab796 100644 --- a/src/node_wire_stripe/__init__.py +++ b/src/node_wire_stripe/__init__.py @@ -1,6 +1,6 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# - -# Connector subpackage: stripe +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# + +# Connector subpackage: stripe diff --git a/src/node_wire_stripe/schema.py b/src/node_wire_stripe/schema.py index b64b491..10e6afb 100644 --- a/src/node_wire_stripe/schema.py +++ b/src/node_wire_stripe/schema.py @@ -1,127 +1,127 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# -from __future__ import annotations - -from typing import Any, Annotated, Literal - -from pydantic import BaseModel, Field, field_validator - - -class ChargeInput(BaseModel): - action: Literal["charge"] = "charge" - amount: Annotated[int, Field(ge=1, le=99_999_999)] - currency: Annotated[str, Field(pattern=r"^[a-z]{3}$")] - source: str - customer_id: str | None = None - description: str | None = None - metadata: dict | None = None - idempotency_key: str | None = Field( - None, description="Optional unique key to prevent duplicate operations." - ) - - @field_validator("currency", mode="before") - @classmethod - def normalize_currency(cls, value: object) -> object: - if isinstance(value, str): - return value.strip().lower() - return value - - -class ChargeOutput(BaseModel): - charge_id: str - receipt_url: str | None = None - - -class CancelSubscriptionInput(BaseModel): - action: Literal["cancel_subscription"] = "cancel_subscription" - subscription_id: str - cancel_at_period_end: bool = False - idempotency_key: str | None = Field( - None, description="Optional unique key to prevent duplicate operations." - ) - - -class CancelSubscriptionOutput(BaseModel): - subscription_id: str - status: str - - -class CreatePaymentIntentInput(BaseModel): - action: Literal["create_payment_intent"] = "create_payment_intent" - amount: Annotated[int, Field(ge=1, le=99_999_999)] - currency: Annotated[str, Field(pattern=r"^[a-z]{3}$")] - customer_id: str | None = None - payment_method: str | None = None - confirm: bool = False - description: str | None = None - metadata: dict | None = None - idempotency_key: str | None = Field( - None, description="Optional unique key to prevent duplicate operations." - ) - - @field_validator("currency", mode="before") - @classmethod - def normalize_currency(cls, value: object) -> object: - if isinstance(value, str): - return value.strip().lower() - return value - - -class CreatePaymentIntentOutput(BaseModel): - payment_intent_id: str - client_secret: str | None = None - status: str - - -class CreateSubscriptionInput(BaseModel): - action: Literal["create_subscription"] = "create_subscription" - customer_id: str - price_id: str - payment_behavior: str = "default_incomplete" - default_payment_method: str | None = None - card_token: str | None = None - metadata: dict | None = None - idempotency_key: str | None = Field( - None, description="Optional unique key to prevent duplicate operations." - ) - - -class CreateSubscriptionOutput(BaseModel): - subscription_id: str - client_secret: str | None = None - status: str - - -class IssueRefundInput(BaseModel): - action: Literal["issue_refund"] = "issue_refund" - charge_id: str | None = None - payment_intent_id: str | None = None - amount: int | None = Field(None, ge=1, le=99999999) - reason: str | None = None - metadata: dict | None = None - idempotency_key: str | None = Field( - None, description="Optional unique key to prevent duplicate operations." - ) - - -class IssueRefundOutput(BaseModel): - refund_id: str - status: str - - -class StripeOperationOutput(BaseModel): - """ - Unified output model for all Stripe actions. - The actual result will be contained in one or more of these fields. - """ - - charge_id: str | None = None - receipt_url: str | None = None - subscription_id: str | None = None - status: str | None = None - payment_intent_id: str | None = None - client_secret: str | None = None - refund_id: str | None = None - raw: dict[str, Any] | None = None +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +from typing import Any, Annotated, Literal + +from pydantic import BaseModel, Field, field_validator + + +class ChargeInput(BaseModel): + action: Literal["charge"] = "charge" + amount: Annotated[int, Field(ge=1, le=99_999_999)] + currency: Annotated[str, Field(pattern=r"^[a-z]{3}$")] + source: str + customer_id: str | None = None + description: str | None = None + metadata: dict | None = None + idempotency_key: str | None = Field( + None, description="Optional unique key to prevent duplicate operations." + ) + + @field_validator("currency", mode="before") + @classmethod + def normalize_currency(cls, value: object) -> object: + if isinstance(value, str): + return value.strip().lower() + return value + + +class ChargeOutput(BaseModel): + charge_id: str + receipt_url: str | None = None + + +class CancelSubscriptionInput(BaseModel): + action: Literal["cancel_subscription"] = "cancel_subscription" + subscription_id: str + cancel_at_period_end: bool = False + idempotency_key: str | None = Field( + None, description="Optional unique key to prevent duplicate operations." + ) + + +class CancelSubscriptionOutput(BaseModel): + subscription_id: str + status: str + + +class CreatePaymentIntentInput(BaseModel): + action: Literal["create_payment_intent"] = "create_payment_intent" + amount: Annotated[int, Field(ge=1, le=99_999_999)] + currency: Annotated[str, Field(pattern=r"^[a-z]{3}$")] + customer_id: str | None = None + payment_method: str | None = None + confirm: bool = False + description: str | None = None + metadata: dict | None = None + idempotency_key: str | None = Field( + None, description="Optional unique key to prevent duplicate operations." + ) + + @field_validator("currency", mode="before") + @classmethod + def normalize_currency(cls, value: object) -> object: + if isinstance(value, str): + return value.strip().lower() + return value + + +class CreatePaymentIntentOutput(BaseModel): + payment_intent_id: str + client_secret: str | None = None + status: str + + +class CreateSubscriptionInput(BaseModel): + action: Literal["create_subscription"] = "create_subscription" + customer_id: str + price_id: str + payment_behavior: str = "default_incomplete" + default_payment_method: str | None = None + card_token: str | None = None + metadata: dict | None = None + idempotency_key: str | None = Field( + None, description="Optional unique key to prevent duplicate operations." + ) + + +class CreateSubscriptionOutput(BaseModel): + subscription_id: str + client_secret: str | None = None + status: str + + +class IssueRefundInput(BaseModel): + action: Literal["issue_refund"] = "issue_refund" + charge_id: str | None = None + payment_intent_id: str | None = None + amount: int | None = Field(None, ge=1, le=99999999) + reason: str | None = None + metadata: dict | None = None + idempotency_key: str | None = Field( + None, description="Optional unique key to prevent duplicate operations." + ) + + +class IssueRefundOutput(BaseModel): + refund_id: str + status: str + + +class StripeOperationOutput(BaseModel): + """ + Unified output model for all Stripe actions. + The actual result will be contained in one or more of these fields. + """ + + charge_id: str | None = None + receipt_url: str | None = None + subscription_id: str | None = None + status: str | None = None + payment_intent_id: str | None = None + client_secret: str | None = None + refund_id: str | None = None + raw: dict[str, Any] | None = None diff --git a/tests/fixtures/connectors_for_tests.yaml b/tests/fixtures/connectors_for_tests.yaml index 8768e17..29c7cd4 100644 --- a/tests/fixtures/connectors_for_tests.yaml +++ b/tests/fixtures/connectors_for_tests.yaml @@ -1,3 +1,8 @@ +## +## SPDX-FileCopyrightText: 2026 AOT Technologies +## SPDX-License-Identifier: Apache-2.0 +## + # Test fixture: mirrors ../config/connectors.yaml but optional connectors not on # the pytest allowlist are disabled (slack, salesforce). # Enabling them here would fail ConnectorFactory.load() when not registered diff --git a/tests/jwt_test_helpers.py b/tests/jwt_test_helpers.py index cadeb05..acf442e 100644 --- a/tests/jwt_test_helpers.py +++ b/tests/jwt_test_helpers.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from __future__ import annotations import os diff --git a/tests/playground/salesforce/__init__.py b/tests/playground/salesforce/__init__.py index e69de29..39bdade 100644 --- a/tests/playground/salesforce/__init__.py +++ b/tests/playground/salesforce/__init__.py @@ -0,0 +1,4 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# diff --git a/tests/test_api_key_compare.py b/tests/test_api_key_compare.py index a5d07b6..c0a516e 100644 --- a/tests/test_api_key_compare.py +++ b/tests/test_api_key_compare.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from __future__ import annotations from node_wire_runtime.caller_identity import api_key_matches diff --git a/tests/test_auth_providers.py b/tests/test_auth_providers.py index 640a0b4..522f3da 100644 --- a/tests/test_auth_providers.py +++ b/tests/test_auth_providers.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """ tests/test_auth_providers.py ============================== diff --git a/tests/test_bandit_report_summary.py b/tests/test_bandit_report_summary.py index 6f7fb3c..f0adfca 100644 --- a/tests/test_bandit_report_summary.py +++ b/tests/test_bandit_report_summary.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """Regression tests for scripts/bandit_report_summary.py (CI log helper).""" from __future__ import annotations diff --git a/tests/test_call_action_policy.py b/tests/test_call_action_policy.py index 6676f05..7d3454d 100644 --- a/tests/test_call_action_policy.py +++ b/tests/test_call_action_policy.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """Regression: ``BaseConnector.call_action`` must honor scope policy via ``run``.""" from __future__ import annotations diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index 95aa029..e6d3b11 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -49,7 +49,7 @@ def test_bindings_entrypoint_api_mode_default(monkeypatch: pytest.MonkeyPatch) - mock_obs.assert_called_once_with(app_name="node-wire") mock_uv.assert_called_once() call_kw = mock_uv.call_args[1] - assert call_kw["host"] == "0.0.0.0" + assert call_kw["host"] == "127.0.0.1" assert call_kw["port"] == 8000 diff --git a/tests/test_fhir_logging.py b/tests/test_fhir_logging.py index 8c7c930..47e15ae 100644 --- a/tests/test_fhir_logging.py +++ b/tests/test_fhir_logging.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from __future__ import annotations import logging diff --git a/tests/test_grpc_async_runner.py b/tests/test_grpc_async_runner.py index fa48c34..4d8d3ce 100644 --- a/tests/test_grpc_async_runner.py +++ b/tests/test_grpc_async_runner.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from __future__ import annotations import asyncio diff --git a/tests/test_grpc_scope_policy.py b/tests/test_grpc_scope_policy.py index a3158ad..8c68e55 100644 --- a/tests/test_grpc_scope_policy.py +++ b/tests/test_grpc_scope_policy.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from __future__ import annotations import asyncio diff --git a/tests/test_jwt_validation.py b/tests/test_jwt_validation.py index fd8de7c..d48095b 100644 --- a/tests/test_jwt_validation.py +++ b/tests/test_jwt_validation.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from __future__ import annotations import time diff --git a/tests/test_log_sanitization.py b/tests/test_log_sanitization.py index 429ba39..56e1f04 100644 --- a/tests/test_log_sanitization.py +++ b/tests/test_log_sanitization.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from __future__ import annotations import logging diff --git a/tests/test_mcp_auth.py b/tests/test_mcp_auth.py index 6a6e5c4..2b1fab9 100644 --- a/tests/test_mcp_auth.py +++ b/tests/test_mcp_auth.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from __future__ import annotations from contextlib import asynccontextmanager diff --git a/tests/test_mcp_oauth_client.py b/tests/test_mcp_oauth_client.py index 89ebe89..33a1233 100644 --- a/tests/test_mcp_oauth_client.py +++ b/tests/test_mcp_oauth_client.py @@ -1,204 +1,208 @@ -from __future__ import annotations - -import json - -import pytest -import httpx - -from node_wire_runtime.mcp_client.client import McpOAuthClient, create_http_mcp_client -from node_wire_runtime.mcp_client.config import ( - AuthClientConfig, - AuthConfig, - McpClientConfig, - McpServerConfig, -) -from node_wire_runtime.mcp_client.discovery import ( - AuthorizationServerMetadata, - DiscoveryResult, - ProtectedResourceMetadata, -) -from node_wire_runtime.mcp_client.oauth_flow import AuthorizationCodeFlow -from node_wire_runtime.mcp_client.storage import ClientRegistration -from node_wire_runtime.mcp_client.token_manager import TokenManager -from node_wire_runtime.mcp_client.token_storage import ( - InMemoryTokenStore, - stored_from_oauth_response, -) - - -MCP_BASE = "https://mcp.example.com/mcp" -ISSUER = "https://issuer.example" - - -def _discovery() -> DiscoveryResult: - return DiscoveryResult( - mcp_server_url=MCP_BASE, - protected_resource=ProtectedResourceMetadata( - resource=MCP_BASE, - authorization_servers=(ISSUER,), - raw={}, - ), - authorization_server=AuthorizationServerMetadata( - issuer=ISSUER, - authorization_endpoint=f"{ISSUER}/authorize", - token_endpoint=f"{ISSUER}/token", - registration_endpoint=None, - scopes_supported=None, - raw={}, - ), - issuer=ISSUER, - ) - - -def _oauth_client(*, handler) -> McpOAuthClient: - config = McpClientConfig( - server=McpServerConfig(url=MCP_BASE), - auth=AuthConfig(client=AuthClientConfig(id="cid", secret="")), - ) - reg = ClientRegistration( - issuer=ISSUER, - client_id="cid", - client_secret=None, - redirect_uris=("http://127.0.0.1:1/callback",), - token_endpoint_auth_method="none", - registered_at="", - ) - store = InMemoryTokenStore() - store.save( - stored_from_oauth_response( - user_id="demo", - mcp_server_url=MCP_BASE, - issuer=ISSUER, - access_token="valid-token", - token_type="Bearer", - expires_in=3600, - refresh_token="rt", - scope=None, - ) - ) - flow = AuthorizationCodeFlow(config, discovery=_discovery(), registration=reg) - mgr = TokenManager( - config, - user_id="demo", - token_store=store, - discovery=_discovery(), - registration=reg, - auth_flow=flow, - ) - transport = httpx.MockTransport(handler) - http = httpx.AsyncClient(transport=transport) - return McpOAuthClient( - MCP_BASE, - config=config, - user_id="demo", - token_manager=mgr, - http_client=http, - ) - - -@pytest.mark.asyncio -async def test_mcp_oauth_client_list_tools_with_bearer() -> None: - calls = [] - - def handler(request: httpx.Request) -> httpx.Response: - calls.append(request.headers.get("Authorization")) - body = json.loads(request.content) if request.content else {} - method = body.get("method") - if method == "initialize": - return httpx.Response( - 200, - json={ - "jsonrpc": "2.0", - "id": body["id"], - "result": {"protocolVersion": "2024-11-05"}, - }, - headers={"Mcp-Session-Id": "sess-1"}, - ) - if method == "notifications/initialized": - return httpx.Response(200, json={"jsonrpc": "2.0", "result": {}}) - if method == "tools/list": - return httpx.Response( - 200, - json={ - "jsonrpc": "2.0", - "id": body["id"], - "result": {"tools": [{"name": "demo.tool"}]}, - }, - ) - return httpx.Response(404) - - client = _oauth_client(handler=handler) - tools = await client.list_tools() - await client.aclose() - assert tools[0]["name"] == "demo.tool" - assert calls[0] == "Bearer valid-token" - - -@pytest.mark.asyncio -async def test_mcp_oauth_client_401_refresh_retry_once() -> None: - list_calls = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal list_calls - if request.url.path.endswith("/token"): - return httpx.Response( - 200, - json={ - "access_token": "refreshed", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "rt2", - }, - ) - body = json.loads(request.content) if request.content else {} - method = body.get("method") - if method == "initialize": - return httpx.Response( - 200, - json={ - "jsonrpc": "2.0", - "id": body["id"], - "result": {"protocolVersion": "2024-11-05"}, - }, - ) - if method == "notifications/initialized": - return httpx.Response(200, json={"jsonrpc": "2.0", "result": {}}) - if method == "tools/list": - list_calls += 1 - if list_calls == 1: - return httpx.Response( - 401, - headers={"WWW-Authenticate": 'Bearer error="invalid_token"'}, - ) - return httpx.Response( - 200, - json={ - "jsonrpc": "2.0", - "id": body["id"], - "result": {"tools": []}, - }, - ) - return httpx.Response(404) - - client = _oauth_client(handler=handler) - tools = await client.list_tools() - await client.aclose() - assert tools == [] - assert list_calls == 2 - - -def test_create_http_mcp_client_legacy_token(monkeypatch: pytest.MonkeyPatch) -> None: - from agents.toolhive import ToolHiveMcpClient - - monkeypatch.setenv("TOOLHIVE_MCP_BEARER_TOKEN", "static") - monkeypatch.delenv("NW_MCP_OAUTH_ENABLED", raising=False) - client = create_http_mcp_client("http://localhost/mcp") - assert isinstance(client, ToolHiveMcpClient) - - -def test_create_http_mcp_client_oauth_enabled(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("TOOLHIVE_MCP_BEARER_TOKEN", raising=False) - monkeypatch.delenv("TOOLHIVE_MCP_API_KEY", raising=False) - monkeypatch.setenv("NW_MCP_OAUTH_ENABLED", "true") - client = create_http_mcp_client("https://mcp.example.com/mcp") - assert isinstance(client, McpOAuthClient) +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +import json + +import pytest +import httpx + +from node_wire_runtime.mcp_client.client import McpOAuthClient, create_http_mcp_client +from node_wire_runtime.mcp_client.config import ( + AuthClientConfig, + AuthConfig, + McpClientConfig, + McpServerConfig, +) +from node_wire_runtime.mcp_client.discovery import ( + AuthorizationServerMetadata, + DiscoveryResult, + ProtectedResourceMetadata, +) +from node_wire_runtime.mcp_client.oauth_flow import AuthorizationCodeFlow +from node_wire_runtime.mcp_client.storage import ClientRegistration +from node_wire_runtime.mcp_client.token_manager import TokenManager +from node_wire_runtime.mcp_client.token_storage import ( + InMemoryTokenStore, + stored_from_oauth_response, +) + + +MCP_BASE = "https://mcp.example.com/mcp" +ISSUER = "https://issuer.example" + + +def _discovery() -> DiscoveryResult: + return DiscoveryResult( + mcp_server_url=MCP_BASE, + protected_resource=ProtectedResourceMetadata( + resource=MCP_BASE, + authorization_servers=(ISSUER,), + raw={}, + ), + authorization_server=AuthorizationServerMetadata( + issuer=ISSUER, + authorization_endpoint=f"{ISSUER}/authorize", + token_endpoint=f"{ISSUER}/token", + registration_endpoint=None, + scopes_supported=None, + raw={}, + ), + issuer=ISSUER, + ) + + +def _oauth_client(*, handler) -> McpOAuthClient: + config = McpClientConfig( + server=McpServerConfig(url=MCP_BASE), + auth=AuthConfig(client=AuthClientConfig(id="cid", secret="")), + ) + reg = ClientRegistration( + issuer=ISSUER, + client_id="cid", + client_secret=None, + redirect_uris=("http://127.0.0.1:1/callback",), + token_endpoint_auth_method="none", + registered_at="", + ) + store = InMemoryTokenStore() + store.save( + stored_from_oauth_response( + user_id="demo", + mcp_server_url=MCP_BASE, + issuer=ISSUER, + access_token="valid-token", + token_type="Bearer", + expires_in=3600, + refresh_token="rt", + scope=None, + ) + ) + flow = AuthorizationCodeFlow(config, discovery=_discovery(), registration=reg) + mgr = TokenManager( + config, + user_id="demo", + token_store=store, + discovery=_discovery(), + registration=reg, + auth_flow=flow, + ) + transport = httpx.MockTransport(handler) + http = httpx.AsyncClient(transport=transport) + return McpOAuthClient( + MCP_BASE, + config=config, + user_id="demo", + token_manager=mgr, + http_client=http, + ) + + +@pytest.mark.asyncio +async def test_mcp_oauth_client_list_tools_with_bearer() -> None: + calls = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request.headers.get("Authorization")) + body = json.loads(request.content) if request.content else {} + method = body.get("method") + if method == "initialize": + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": body["id"], + "result": {"protocolVersion": "2024-11-05"}, + }, + headers={"Mcp-Session-Id": "sess-1"}, + ) + if method == "notifications/initialized": + return httpx.Response(200, json={"jsonrpc": "2.0", "result": {}}) + if method == "tools/list": + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": body["id"], + "result": {"tools": [{"name": "demo.tool"}]}, + }, + ) + return httpx.Response(404) + + client = _oauth_client(handler=handler) + tools = await client.list_tools() + await client.aclose() + assert tools[0]["name"] == "demo.tool" + assert calls[0] == "Bearer valid-token" + + +@pytest.mark.asyncio +async def test_mcp_oauth_client_401_refresh_retry_once() -> None: + list_calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal list_calls + if request.url.path.endswith("/token"): + return httpx.Response( + 200, + json={ + "access_token": "refreshed", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "rt2", + }, + ) + body = json.loads(request.content) if request.content else {} + method = body.get("method") + if method == "initialize": + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": body["id"], + "result": {"protocolVersion": "2024-11-05"}, + }, + ) + if method == "notifications/initialized": + return httpx.Response(200, json={"jsonrpc": "2.0", "result": {}}) + if method == "tools/list": + list_calls += 1 + if list_calls == 1: + return httpx.Response( + 401, + headers={"WWW-Authenticate": 'Bearer error="invalid_token"'}, + ) + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": body["id"], + "result": {"tools": []}, + }, + ) + return httpx.Response(404) + + client = _oauth_client(handler=handler) + tools = await client.list_tools() + await client.aclose() + assert tools == [] + assert list_calls == 2 + + +def test_create_http_mcp_client_legacy_token(monkeypatch: pytest.MonkeyPatch) -> None: + from agents.toolhive import ToolHiveMcpClient + + monkeypatch.setenv("TOOLHIVE_MCP_BEARER_TOKEN", "static") + monkeypatch.delenv("NW_MCP_OAUTH_ENABLED", raising=False) + client = create_http_mcp_client("http://localhost/mcp") + assert isinstance(client, ToolHiveMcpClient) + + +def test_create_http_mcp_client_oauth_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("TOOLHIVE_MCP_BEARER_TOKEN", raising=False) + monkeypatch.delenv("TOOLHIVE_MCP_API_KEY", raising=False) + monkeypatch.setenv("NW_MCP_OAUTH_ENABLED", "true") + client = create_http_mcp_client("https://mcp.example.com/mcp") + assert isinstance(client, McpOAuthClient) diff --git a/tests/test_mcp_oauth_config.py b/tests/test_mcp_oauth_config.py index b87c11e..99f2ed3 100644 --- a/tests/test_mcp_oauth_config.py +++ b/tests/test_mcp_oauth_config.py @@ -1,98 +1,102 @@ -from __future__ import annotations - -import pytest - -from node_wire_runtime.mcp_client.config import ( - AuthConfig, - AuthRedirectConfig, - McpClientConfig, - McpServerConfig, - RedirectMode, - canonicalize_mcp_server_url, -) -from node_wire_runtime.mcp_client.exceptions import McpOAuthConfigurationError - - -def test_canonicalize_strips_trailing_slash() -> None: - assert canonicalize_mcp_server_url("https://mcp.example.com/mcp/") == ( - "https://mcp.example.com/mcp" - ) - - -def test_mcp_client_config_requires_https_or_http() -> None: - with pytest.raises(ValueError, match="http or https"): - McpClientConfig(server=McpServerConfig(url="ftp://bad")) - - -def test_mcp_client_config_populates_aliases() -> None: - cfg = McpClientConfig.model_validate( - { - "server": {"url": "https://mcp.example.com/"}, - "auth": { - "discovery": {"cacheTtlSeconds": 120}, - "client": {"clientId": "cid", "clientSecret": "sec"}, - }, - } - ) - assert cfg.auth.discovery.cache_ttl_seconds == 120 - assert cfg.auth.client.id == "cid" - assert cfg.canonical_server_url == "https://mcp.example.com" - - -def test_production_requires_https_mcp_server() -> None: - with pytest.raises(McpOAuthConfigurationError, match="mcp.server.url"): - McpClientConfig( - server=McpServerConfig(url="http://mcp.example.com/mcp"), - auth=AuthConfig( - production=True, - redirect=AuthRedirectConfig( - mode=RedirectMode.CONFIGURED_URL, - url="https://app.example.com/callback", - ), - ), - ) - - -def test_production_requires_configured_url_https_redirect() -> None: - with pytest.raises(McpOAuthConfigurationError, match="redirect.mode"): - McpClientConfig( - server=McpServerConfig(url="https://mcp.example.com/mcp"), - auth=AuthConfig(production=True), - ) - - with pytest.raises(McpOAuthConfigurationError, match="redirect.url"): - McpClientConfig( - server=McpServerConfig(url="https://mcp.example.com/mcp"), - auth=AuthConfig( - production=True, - redirect=AuthRedirectConfig( - mode=RedirectMode.CONFIGURED_URL, - url="http://127.0.0.1:8765/callback", - ), - ), - ) - - -def test_production_valid_config() -> None: - cfg = McpClientConfig( - server=McpServerConfig(url="https://mcp.example.com/mcp"), - auth=AuthConfig( - production=True, - redirect=AuthRedirectConfig( - mode=RedirectMode.CONFIGURED_URL, - url="https://app.example.com/oauth/callback", - ), - ), - ) - assert cfg.auth.production is True - assert cfg.auth.redirect.mode == RedirectMode.CONFIGURED_URL - - -def test_config_from_env_production(monkeypatch: pytest.MonkeyPatch) -> None: - from node_wire_runtime.mcp_client.env_config import config_from_env - - monkeypatch.setenv("NW_MCP_OAUTH_PRODUCTION", "true") - monkeypatch.setenv("NW_MCP_OAUTH_REDIRECT_URL", "https://app.example.com/callback") - cfg = config_from_env(server_url="https://mcp.example.com/mcp") - assert cfg.auth.production is True - assert cfg.auth.redirect.mode == RedirectMode.CONFIGURED_URL +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +import pytest + +from node_wire_runtime.mcp_client.config import ( + AuthConfig, + AuthRedirectConfig, + McpClientConfig, + McpServerConfig, + RedirectMode, + canonicalize_mcp_server_url, +) +from node_wire_runtime.mcp_client.exceptions import McpOAuthConfigurationError + + +def test_canonicalize_strips_trailing_slash() -> None: + assert canonicalize_mcp_server_url("https://mcp.example.com/mcp/") == ( + "https://mcp.example.com/mcp" + ) + + +def test_mcp_client_config_requires_https_or_http() -> None: + with pytest.raises(ValueError, match="http or https"): + McpClientConfig(server=McpServerConfig(url="ftp://bad")) + + +def test_mcp_client_config_populates_aliases() -> None: + cfg = McpClientConfig.model_validate( + { + "server": {"url": "https://mcp.example.com/"}, + "auth": { + "discovery": {"cacheTtlSeconds": 120}, + "client": {"clientId": "cid", "clientSecret": "sec"}, + }, + } + ) + assert cfg.auth.discovery.cache_ttl_seconds == 120 + assert cfg.auth.client.id == "cid" + assert cfg.canonical_server_url == "https://mcp.example.com" + + +def test_production_requires_https_mcp_server() -> None: + with pytest.raises(McpOAuthConfigurationError, match="mcp.server.url"): + McpClientConfig( + server=McpServerConfig(url="http://mcp.example.com/mcp"), + auth=AuthConfig( + production=True, + redirect=AuthRedirectConfig( + mode=RedirectMode.CONFIGURED_URL, + url="https://app.example.com/callback", + ), + ), + ) + + +def test_production_requires_configured_url_https_redirect() -> None: + with pytest.raises(McpOAuthConfigurationError, match="redirect.mode"): + McpClientConfig( + server=McpServerConfig(url="https://mcp.example.com/mcp"), + auth=AuthConfig(production=True), + ) + + with pytest.raises(McpOAuthConfigurationError, match="redirect.url"): + McpClientConfig( + server=McpServerConfig(url="https://mcp.example.com/mcp"), + auth=AuthConfig( + production=True, + redirect=AuthRedirectConfig( + mode=RedirectMode.CONFIGURED_URL, + url="http://127.0.0.1:8765/callback", + ), + ), + ) + + +def test_production_valid_config() -> None: + cfg = McpClientConfig( + server=McpServerConfig(url="https://mcp.example.com/mcp"), + auth=AuthConfig( + production=True, + redirect=AuthRedirectConfig( + mode=RedirectMode.CONFIGURED_URL, + url="https://app.example.com/oauth/callback", + ), + ), + ) + assert cfg.auth.production is True + assert cfg.auth.redirect.mode == RedirectMode.CONFIGURED_URL + + +def test_config_from_env_production(monkeypatch: pytest.MonkeyPatch) -> None: + from node_wire_runtime.mcp_client.env_config import config_from_env + + monkeypatch.setenv("NW_MCP_OAUTH_PRODUCTION", "true") + monkeypatch.setenv("NW_MCP_OAUTH_REDIRECT_URL", "https://app.example.com/callback") + cfg = config_from_env(server_url="https://mcp.example.com/mcp") + assert cfg.auth.production is True + assert cfg.auth.redirect.mode == RedirectMode.CONFIGURED_URL diff --git a/tests/test_mcp_oauth_conformance.py b/tests/test_mcp_oauth_conformance.py index 050ba68..d97768d 100644 --- a/tests/test_mcp_oauth_conformance.py +++ b/tests/test_mcp_oauth_conformance.py @@ -1,93 +1,97 @@ -""" -Section 11 conformance checklist — unit/integration coverage with mock AS + MCP RS. -""" - -from __future__ import annotations - -import pytest -import httpx - -from node_wire_runtime.mcp_client.challenges import parse_www_authenticate -from node_wire_runtime.mcp_client.config import McpClientConfig, McpServerConfig -from node_wire_runtime.mcp_client.discovery import discover, DiscoveryCache -from node_wire_runtime.mcp_client.dcr import resolve_client_registration -from node_wire_runtime.mcp_client.oauth_flow import AuthorizationCodeFlow -from node_wire_runtime.mcp_client.redirect_listener import AuthorizationCallback -from node_wire_runtime.mcp_client.storage import RegistrationStore - - -@pytest.mark.asyncio -async def test_conformance_discovery_and_dcr_and_pkce_flow(tmp_path) -> None: - """Checklist: discovery, DCR, PKCE+resource, state validation, bearer usage.""" - prm = { - "resource": "https://mcp.example.com/mcp", - "authorization_servers": ["https://issuer.example"], - } - as_meta = { - "issuer": "https://issuer.example", - "authorization_endpoint": "https://issuer.example/authorize", - "token_endpoint": "https://issuer.example/token", - "registration_endpoint": "https://issuer.example/register", - } - - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - if path.endswith("/prm") or "oauth-protected-resource" in path: - return httpx.Response(200, json=prm) - if "oauth-authorization-server" in path: - return httpx.Response(200, json=as_meta) - if path.endswith("/register"): - return httpx.Response(201, json={"client_id": "conf-client"}) - if path.endswith("/token"): - body = request.read().decode() - assert "resource=" in body - assert "code_verifier=" in body - return httpx.Response( - 200, - json={ - "access_token": "conf-access", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "conf-refresh", - }, - ) - return httpx.Response(404) - - config = McpClientConfig(server=McpServerConfig(url="https://mcp.example.com/mcp")) - config.auth.registration_store_path = str(tmp_path) - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http: - discovery = await discover( - config, - www_authenticate='Bearer resource_metadata="https://issuer.example/prm"', - cache=DiscoveryCache(3600), - http_client=http, - ) - reg = await resolve_client_registration( - config, - discovery, - redirect_uris=["http://127.0.0.1:42/callback"], - store=RegistrationStore(tmp_path), - http_client=http, - ) - flow = AuthorizationCodeFlow(config, discovery=discovery, registration=reg) - session, url = await flow.prepare_authorization_session( - redirect_uri="http://127.0.0.1:42/callback" - ) - assert "code_challenge_method=S256" in url - assert "resource=" in url - assert len(session.state) >= 16 - callback = AuthorizationCallback( - code="code-1", - state=session.state, - error=None, - error_description=None, - ) - code = flow.validate_callback(callback, expected_state=session.state) - tokens = await flow.exchange_code(code, session=session, http_client=http) - - assert tokens.access_token == "conf-access" - assert reg.client_id == "conf-client" - challenge = parse_www_authenticate('Bearer error="invalid_token"') - assert challenge is not None - assert challenge.treat_as_unauthorized +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +""" +Section 11 conformance checklist — unit/integration coverage with mock AS + MCP RS. +""" + +from __future__ import annotations + +import pytest +import httpx + +from node_wire_runtime.mcp_client.challenges import parse_www_authenticate +from node_wire_runtime.mcp_client.config import McpClientConfig, McpServerConfig +from node_wire_runtime.mcp_client.discovery import discover, DiscoveryCache +from node_wire_runtime.mcp_client.dcr import resolve_client_registration +from node_wire_runtime.mcp_client.oauth_flow import AuthorizationCodeFlow +from node_wire_runtime.mcp_client.redirect_listener import AuthorizationCallback +from node_wire_runtime.mcp_client.storage import RegistrationStore + + +@pytest.mark.asyncio +async def test_conformance_discovery_and_dcr_and_pkce_flow(tmp_path) -> None: + """Checklist: discovery, DCR, PKCE+resource, state validation, bearer usage.""" + prm = { + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://issuer.example"], + } + as_meta = { + "issuer": "https://issuer.example", + "authorization_endpoint": "https://issuer.example/authorize", + "token_endpoint": "https://issuer.example/token", + "registration_endpoint": "https://issuer.example/register", + } + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.endswith("/prm") or "oauth-protected-resource" in path: + return httpx.Response(200, json=prm) + if "oauth-authorization-server" in path: + return httpx.Response(200, json=as_meta) + if path.endswith("/register"): + return httpx.Response(201, json={"client_id": "conf-client"}) + if path.endswith("/token"): + body = request.read().decode() + assert "resource=" in body + assert "code_verifier=" in body + return httpx.Response( + 200, + json={ + "access_token": "conf-access", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "conf-refresh", + }, + ) + return httpx.Response(404) + + config = McpClientConfig(server=McpServerConfig(url="https://mcp.example.com/mcp")) + config.auth.registration_store_path = str(tmp_path) + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as http: + discovery = await discover( + config, + www_authenticate='Bearer resource_metadata="https://issuer.example/prm"', + cache=DiscoveryCache(3600), + http_client=http, + ) + reg = await resolve_client_registration( + config, + discovery, + redirect_uris=["http://127.0.0.1:42/callback"], + store=RegistrationStore(tmp_path), + http_client=http, + ) + flow = AuthorizationCodeFlow(config, discovery=discovery, registration=reg) + session, url = await flow.prepare_authorization_session( + redirect_uri="http://127.0.0.1:42/callback" + ) + assert "code_challenge_method=S256" in url + assert "resource=" in url + assert len(session.state) >= 16 + callback = AuthorizationCallback( + code="code-1", + state=session.state, + error=None, + error_description=None, + ) + code = flow.validate_callback(callback, expected_state=session.state) + tokens = await flow.exchange_code(code, session=session, http_client=http) + + assert tokens.access_token == "conf-access" + assert reg.client_id == "conf-client" + challenge = parse_www_authenticate('Bearer error="invalid_token"') + assert challenge is not None + assert challenge.treat_as_unauthorized diff --git a/tests/test_mcp_oauth_dcr.py b/tests/test_mcp_oauth_dcr.py index 0bc903d..a11c2ca 100644 --- a/tests/test_mcp_oauth_dcr.py +++ b/tests/test_mcp_oauth_dcr.py @@ -1,119 +1,123 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -import httpx - -from node_wire_runtime.mcp_client.config import ( - AuthClientConfig, - AuthConfig, - McpClientConfig, - McpServerConfig, -) -from node_wire_runtime.mcp_client.discovery import ( - AuthorizationServerMetadata, - DiscoveryResult, - ProtectedResourceMetadata, -) -from node_wire_runtime.mcp_client.dcr import resolve_client_registration, register_dynamic_client -from node_wire_runtime.mcp_client.storage import RegistrationStore - - -def _discovery(*, with_dcr: bool = True) -> DiscoveryResult: - reg = "https://issuer.example/register" if with_dcr else None - return DiscoveryResult( - mcp_server_url="https://mcp.example.com/mcp", - protected_resource=ProtectedResourceMetadata( - resource="https://mcp.example.com/mcp", - authorization_servers=("https://issuer.example",), - raw={}, - ), - authorization_server=AuthorizationServerMetadata( - issuer="https://issuer.example", - authorization_endpoint="https://issuer.example/authorize", - token_endpoint="https://issuer.example/token", - registration_endpoint=reg, - scopes_supported=None, - raw={}, - ), - issuer="https://issuer.example", - ) - - -def _config(**kwargs) -> McpClientConfig: - return McpClientConfig( - server=McpServerConfig(url="https://mcp.example.com/mcp"), - auth=AuthConfig(**kwargs), - ) - - -@pytest.mark.asyncio -async def test_dcr_registers_and_persists(tmp_path: Path) -> None: - captured = {} - - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path.endswith("/register"): - captured["body"] = request.read().decode() - return httpx.Response( - 201, - json={"client_id": "dyn-client", "client_secret": None}, - ) - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - cfg = _config(registration_store_path=str(tmp_path)) - store = RegistrationStore(tmp_path) - async with httpx.AsyncClient(transport=transport) as client: - reg = await resolve_client_registration( - cfg, - _discovery(), - redirect_uris=["http://127.0.0.1:8765/callback"], - store=store, - http_client=client, - ) - again = await resolve_client_registration( - cfg, - _discovery(), - redirect_uris=["http://127.0.0.1:8765/callback"], - store=store, - http_client=client, - ) - - assert reg.client_id == "dyn-client" - assert again.client_id == "dyn-client" - assert "authorization_code" in captured.get("body", "") - assert store.get("https://issuer.example") is not None - - -@pytest.mark.asyncio -async def test_configured_client_id_skips_dcr() -> None: - cfg = _config(client=AuthClientConfig(id="static-id", secret="")) - reg = await resolve_client_registration( - cfg, - _discovery(), - redirect_uris=["http://127.0.0.1:1/callback"], - ) - assert reg.client_id == "static-id" - - -@pytest.mark.asyncio -async def test_register_dynamic_client_payload() -> None: - def handler(request: httpx.Request) -> httpx.Response: - import json - - body = json.loads(request.content) - assert body["token_endpoint_auth_method"] == "none" - assert "authorization_code" in body["grant_types"] - return httpx.Response(200, json={"client_id": "c1"}) - - transport = httpx.MockTransport(handler) - cfg = _config() - async with httpx.AsyncClient(transport=transport) as client: - reg = await register_dynamic_client( - cfg, - _discovery(), - redirect_uris=["http://127.0.0.1:9/callback"], - http_client=client, - ) - assert reg.client_id == "c1" +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +from pathlib import Path + +import pytest +import httpx + +from node_wire_runtime.mcp_client.config import ( + AuthClientConfig, + AuthConfig, + McpClientConfig, + McpServerConfig, +) +from node_wire_runtime.mcp_client.discovery import ( + AuthorizationServerMetadata, + DiscoveryResult, + ProtectedResourceMetadata, +) +from node_wire_runtime.mcp_client.dcr import resolve_client_registration, register_dynamic_client +from node_wire_runtime.mcp_client.storage import RegistrationStore + + +def _discovery(*, with_dcr: bool = True) -> DiscoveryResult: + reg = "https://issuer.example/register" if with_dcr else None + return DiscoveryResult( + mcp_server_url="https://mcp.example.com/mcp", + protected_resource=ProtectedResourceMetadata( + resource="https://mcp.example.com/mcp", + authorization_servers=("https://issuer.example",), + raw={}, + ), + authorization_server=AuthorizationServerMetadata( + issuer="https://issuer.example", + authorization_endpoint="https://issuer.example/authorize", + token_endpoint="https://issuer.example/token", + registration_endpoint=reg, + scopes_supported=None, + raw={}, + ), + issuer="https://issuer.example", + ) + + +def _config(**kwargs) -> McpClientConfig: + return McpClientConfig( + server=McpServerConfig(url="https://mcp.example.com/mcp"), + auth=AuthConfig(**kwargs), + ) + + +@pytest.mark.asyncio +async def test_dcr_registers_and_persists(tmp_path: Path) -> None: + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/register"): + captured["body"] = request.read().decode() + return httpx.Response( + 201, + json={"client_id": "dyn-client", "client_secret": None}, + ) + return httpx.Response(404) + + transport = httpx.MockTransport(handler) + cfg = _config(registration_store_path=str(tmp_path)) + store = RegistrationStore(tmp_path) + async with httpx.AsyncClient(transport=transport) as client: + reg = await resolve_client_registration( + cfg, + _discovery(), + redirect_uris=["http://127.0.0.1:8765/callback"], + store=store, + http_client=client, + ) + again = await resolve_client_registration( + cfg, + _discovery(), + redirect_uris=["http://127.0.0.1:8765/callback"], + store=store, + http_client=client, + ) + + assert reg.client_id == "dyn-client" + assert again.client_id == "dyn-client" + assert "authorization_code" in captured.get("body", "") + assert store.get("https://issuer.example") is not None + + +@pytest.mark.asyncio +async def test_configured_client_id_skips_dcr() -> None: + cfg = _config(client=AuthClientConfig(id="static-id", secret="")) + reg = await resolve_client_registration( + cfg, + _discovery(), + redirect_uris=["http://127.0.0.1:1/callback"], + ) + assert reg.client_id == "static-id" + + +@pytest.mark.asyncio +async def test_register_dynamic_client_payload() -> None: + def handler(request: httpx.Request) -> httpx.Response: + import json + + body = json.loads(request.content) + assert body["token_endpoint_auth_method"] == "none" + assert "authorization_code" in body["grant_types"] + return httpx.Response(200, json={"client_id": "c1"}) + + transport = httpx.MockTransport(handler) + cfg = _config() + async with httpx.AsyncClient(transport=transport) as client: + reg = await register_dynamic_client( + cfg, + _discovery(), + redirect_uris=["http://127.0.0.1:9/callback"], + http_client=client, + ) + assert reg.client_id == "c1" diff --git a/tests/test_mcp_oauth_discovery.py b/tests/test_mcp_oauth_discovery.py index ed6541c..5ede621 100644 --- a/tests/test_mcp_oauth_discovery.py +++ b/tests/test_mcp_oauth_discovery.py @@ -1,98 +1,102 @@ -from __future__ import annotations - -import pytest -import httpx - -from node_wire_runtime.mcp_client.config import McpClientConfig, McpServerConfig -from node_wire_runtime.mcp_client.discovery import ( - DiscoveryCache, - discover, - parse_resource_metadata_url, - protected_resource_metadata_well_known_url, - select_issuer, -) -from node_wire_runtime.mcp_client.exceptions import McpOAuthDiscoveryError - - -def _config() -> McpClientConfig: - return McpClientConfig(server=McpServerConfig(url="https://mcp.example.com/mcp")) - - -def test_parse_resource_metadata_url() -> None: - header = 'Bearer error="invalid_token", resource_metadata="https://as.example/prm"' - assert parse_resource_metadata_url(header) == "https://as.example/prm" - assert parse_resource_metadata_url(None) is None - - -def test_protected_resource_well_known_url() -> None: - url = protected_resource_metadata_well_known_url("https://mcp.example.com/mcp/") - assert url == "https://mcp.example.com/.well-known/oauth-protected-resource" - - -def test_select_issuer_override_must_be_listed() -> None: - with pytest.raises(McpOAuthDiscoveryError): - select_issuer( - ("https://issuer.a",), - override="https://issuer.b", - ) - - -@pytest.mark.asyncio -async def test_discover_full_chain() -> None: - prm = { - "resource": "https://mcp.example.com/mcp", - "authorization_servers": ["https://issuer.example"], - } - as_meta = { - "issuer": "https://issuer.example", - "authorization_endpoint": "https://issuer.example/authorize", - "token_endpoint": "https://issuer.example/token", - "registration_endpoint": "https://issuer.example/register", - } - - def handler(request: httpx.Request) -> httpx.Response: - if "oauth-protected-resource" in request.url.path: - return httpx.Response(200, json=prm) - if "oauth-authorization-server" in request.url.path: - return httpx.Response(200, json=as_meta) - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as client: - result = await discover(_config(), cache=DiscoveryCache(3600), http_client=client) - - assert result.issuer == "https://issuer.example" - assert result.authorization_server.token_endpoint.endswith("/token") - assert result.protected_resource.authorization_servers[0] == "https://issuer.example" - - -@pytest.mark.asyncio -async def test_discover_uses_cache() -> None: - calls = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal calls - calls += 1 - if "oauth-protected-resource" in request.url.path: - return httpx.Response( - 200, - json={ - "resource": "https://mcp.example.com/mcp", - "authorization_servers": ["https://issuer.example"], - }, - ) - return httpx.Response( - 200, - json={ - "issuer": "https://issuer.example", - "authorization_endpoint": "https://issuer.example/authorize", - "token_endpoint": "https://issuer.example/token", - }, - ) - - transport = httpx.MockTransport(handler) - cache = DiscoveryCache(3600) - async with httpx.AsyncClient(transport=transport) as client: - await discover(_config(), cache=cache, http_client=client) - await discover(_config(), cache=cache, http_client=client) - assert calls == 2 # PRM + AS once; second discover hits cache +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +import pytest +import httpx + +from node_wire_runtime.mcp_client.config import McpClientConfig, McpServerConfig +from node_wire_runtime.mcp_client.discovery import ( + DiscoveryCache, + discover, + parse_resource_metadata_url, + protected_resource_metadata_well_known_url, + select_issuer, +) +from node_wire_runtime.mcp_client.exceptions import McpOAuthDiscoveryError + + +def _config() -> McpClientConfig: + return McpClientConfig(server=McpServerConfig(url="https://mcp.example.com/mcp")) + + +def test_parse_resource_metadata_url() -> None: + header = 'Bearer error="invalid_token", resource_metadata="https://as.example/prm"' + assert parse_resource_metadata_url(header) == "https://as.example/prm" + assert parse_resource_metadata_url(None) is None + + +def test_protected_resource_well_known_url() -> None: + url = protected_resource_metadata_well_known_url("https://mcp.example.com/mcp/") + assert url == "https://mcp.example.com/.well-known/oauth-protected-resource" + + +def test_select_issuer_override_must_be_listed() -> None: + with pytest.raises(McpOAuthDiscoveryError): + select_issuer( + ("https://issuer.a",), + override="https://issuer.b", + ) + + +@pytest.mark.asyncio +async def test_discover_full_chain() -> None: + prm = { + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://issuer.example"], + } + as_meta = { + "issuer": "https://issuer.example", + "authorization_endpoint": "https://issuer.example/authorize", + "token_endpoint": "https://issuer.example/token", + "registration_endpoint": "https://issuer.example/register", + } + + def handler(request: httpx.Request) -> httpx.Response: + if "oauth-protected-resource" in request.url.path: + return httpx.Response(200, json=prm) + if "oauth-authorization-server" in request.url.path: + return httpx.Response(200, json=as_meta) + return httpx.Response(404) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as client: + result = await discover(_config(), cache=DiscoveryCache(3600), http_client=client) + + assert result.issuer == "https://issuer.example" + assert result.authorization_server.token_endpoint.endswith("/token") + assert result.protected_resource.authorization_servers[0] == "https://issuer.example" + + +@pytest.mark.asyncio +async def test_discover_uses_cache() -> None: + calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + if "oauth-protected-resource" in request.url.path: + return httpx.Response( + 200, + json={ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://issuer.example"], + }, + ) + return httpx.Response( + 200, + json={ + "issuer": "https://issuer.example", + "authorization_endpoint": "https://issuer.example/authorize", + "token_endpoint": "https://issuer.example/token", + }, + ) + + transport = httpx.MockTransport(handler) + cache = DiscoveryCache(3600) + async with httpx.AsyncClient(transport=transport) as client: + await discover(_config(), cache=cache, http_client=client) + await discover(_config(), cache=cache, http_client=client) + assert calls == 2 # PRM + AS once; second discover hits cache diff --git a/tests/test_mcp_oauth_flow.py b/tests/test_mcp_oauth_flow.py index ea68554..7aaaf32 100644 --- a/tests/test_mcp_oauth_flow.py +++ b/tests/test_mcp_oauth_flow.py @@ -1,139 +1,143 @@ -from __future__ import annotations - -import pytest - -from node_wire_runtime.mcp_client.config import ( - AuthClientConfig, - AuthConfig, - McpClientConfig, - McpServerConfig, -) -from node_wire_runtime.mcp_client.discovery import ( - AuthorizationServerMetadata, - DiscoveryResult, - ProtectedResourceMetadata, -) -from node_wire_runtime.mcp_client.oauth_flow import ( - AuthorizationCodeFlow, - build_authorization_url, - generate_pkce_pair, - generate_state, -) -from node_wire_runtime.mcp_client.redirect_listener import AuthorizationCallback -from node_wire_runtime.mcp_client.exceptions import McpOAuthSecurityError -from node_wire_runtime.mcp_client.storage import ClientRegistration - - -def _discovery() -> DiscoveryResult: - return DiscoveryResult( - mcp_server_url="https://mcp.example.com/mcp", - protected_resource=ProtectedResourceMetadata( - resource="https://mcp.example.com/mcp", - authorization_servers=("https://issuer.example",), - raw={}, - ), - authorization_server=AuthorizationServerMetadata( - issuer="https://issuer.example", - authorization_endpoint="https://issuer.example/authorize", - token_endpoint="https://issuer.example/token", - registration_endpoint=None, - scopes_supported=None, - raw={}, - ), - issuer="https://issuer.example", - ) - - -def _flow() -> AuthorizationCodeFlow: - cfg = McpClientConfig( - server=McpServerConfig(url="https://mcp.example.com/mcp"), - auth=AuthConfig(client=AuthClientConfig(id="cid", secret="")), - ) - return AuthorizationCodeFlow( - cfg, - discovery=_discovery(), - registration=ClientRegistration( - issuer="https://issuer.example", - client_id="cid", - client_secret=None, - redirect_uris=("http://127.0.0.1:1/callback",), - token_endpoint_auth_method="none", - registered_at="", - ), - ) - - -def test_pkce_s256_challenge() -> None: - pair = generate_pkce_pair() - assert 43 <= len(pair.code_verifier) <= 128 - assert pair.code_challenge - assert "=" not in pair.code_challenge - - -def test_build_authorization_url_includes_resource() -> None: - pkce = generate_pkce_pair() - url = build_authorization_url( - authorization_endpoint="https://issuer.example/authorize", - client_id="cid", - redirect_uri="http://127.0.0.1:1/callback", - scope="tools", - state="st", - pkce=pkce, - resource="https://mcp.example.com/mcp", - ) - assert "resource=https%3A%2F%2Fmcp.example.com%2Fmcp" in url - assert "code_challenge_method=S256" in url - - -def test_state_entropy() -> None: - assert len(generate_state()) >= 16 - - -def test_validate_callback_state_mismatch() -> None: - flow = _flow() - with pytest.raises(McpOAuthSecurityError, match="state mismatch"): - flow.validate_callback( - AuthorizationCallback(code="c", state="wrong", error=None, error_description=None), - expected_state="expected", - ) - - -@pytest.mark.asyncio -async def test_exchange_code() -> None: - import httpx - - verifier_holder = {} - - def handler(request: httpx.Request) -> httpx.Response: - body = request.read().decode() - if "token" in request.url.path: - assert "resource=https" in body or "resource=" in body - assert "code_verifier=" in body - return httpx.Response( - 200, - json={ - "access_token": "at", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "rt", - }, - ) - return httpx.Response(404) - - flow = _flow() - pkce = generate_pkce_pair() - verifier_holder["v"] = pkce.code_verifier - from node_wire_runtime.mcp_client.oauth_flow import AuthorizationSession - - session = AuthorizationSession( - state="s", - pkce=pkce, - redirect_uri="http://127.0.0.1:1/callback", - resource="https://mcp.example.com/mcp", - ) - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as client: - tokens = await flow.exchange_code("auth-code", session=session, http_client=client) - - assert tokens.access_token == "at" - assert tokens.refresh_token == "rt" +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +import pytest + +from node_wire_runtime.mcp_client.config import ( + AuthClientConfig, + AuthConfig, + McpClientConfig, + McpServerConfig, +) +from node_wire_runtime.mcp_client.discovery import ( + AuthorizationServerMetadata, + DiscoveryResult, + ProtectedResourceMetadata, +) +from node_wire_runtime.mcp_client.oauth_flow import ( + AuthorizationCodeFlow, + build_authorization_url, + generate_pkce_pair, + generate_state, +) +from node_wire_runtime.mcp_client.redirect_listener import AuthorizationCallback +from node_wire_runtime.mcp_client.exceptions import McpOAuthSecurityError +from node_wire_runtime.mcp_client.storage import ClientRegistration + + +def _discovery() -> DiscoveryResult: + return DiscoveryResult( + mcp_server_url="https://mcp.example.com/mcp", + protected_resource=ProtectedResourceMetadata( + resource="https://mcp.example.com/mcp", + authorization_servers=("https://issuer.example",), + raw={}, + ), + authorization_server=AuthorizationServerMetadata( + issuer="https://issuer.example", + authorization_endpoint="https://issuer.example/authorize", + token_endpoint="https://issuer.example/token", + registration_endpoint=None, + scopes_supported=None, + raw={}, + ), + issuer="https://issuer.example", + ) + + +def _flow() -> AuthorizationCodeFlow: + cfg = McpClientConfig( + server=McpServerConfig(url="https://mcp.example.com/mcp"), + auth=AuthConfig(client=AuthClientConfig(id="cid", secret="")), + ) + return AuthorizationCodeFlow( + cfg, + discovery=_discovery(), + registration=ClientRegistration( + issuer="https://issuer.example", + client_id="cid", + client_secret=None, + redirect_uris=("http://127.0.0.1:1/callback",), + token_endpoint_auth_method="none", + registered_at="", + ), + ) + + +def test_pkce_s256_challenge() -> None: + pair = generate_pkce_pair() + assert 43 <= len(pair.code_verifier) <= 128 + assert pair.code_challenge + assert "=" not in pair.code_challenge + + +def test_build_authorization_url_includes_resource() -> None: + pkce = generate_pkce_pair() + url = build_authorization_url( + authorization_endpoint="https://issuer.example/authorize", + client_id="cid", + redirect_uri="http://127.0.0.1:1/callback", + scope="tools", + state="st", + pkce=pkce, + resource="https://mcp.example.com/mcp", + ) + assert "resource=https%3A%2F%2Fmcp.example.com%2Fmcp" in url + assert "code_challenge_method=S256" in url + + +def test_state_entropy() -> None: + assert len(generate_state()) >= 16 + + +def test_validate_callback_state_mismatch() -> None: + flow = _flow() + with pytest.raises(McpOAuthSecurityError, match="state mismatch"): + flow.validate_callback( + AuthorizationCallback(code="c", state="wrong", error=None, error_description=None), + expected_state="expected", + ) + + +@pytest.mark.asyncio +async def test_exchange_code() -> None: + import httpx + + verifier_holder = {} + + def handler(request: httpx.Request) -> httpx.Response: + body = request.read().decode() + if "token" in request.url.path: + assert "resource=https" in body or "resource=" in body + assert "code_verifier=" in body + return httpx.Response( + 200, + json={ + "access_token": "at", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "rt", + }, + ) + return httpx.Response(404) + + flow = _flow() + pkce = generate_pkce_pair() + verifier_holder["v"] = pkce.code_verifier + from node_wire_runtime.mcp_client.oauth_flow import AuthorizationSession + + session = AuthorizationSession( + state="s", + pkce=pkce, + redirect_uri="http://127.0.0.1:1/callback", + resource="https://mcp.example.com/mcp", + ) + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as client: + tokens = await flow.exchange_code("auth-code", session=session, http_client=client) + + assert tokens.access_token == "at" + assert tokens.refresh_token == "rt" diff --git a/tests/test_mcp_oauth_token_lifecycle.py b/tests/test_mcp_oauth_token_lifecycle.py index aeea925..cfa663f 100644 --- a/tests/test_mcp_oauth_token_lifecycle.py +++ b/tests/test_mcp_oauth_token_lifecycle.py @@ -1,183 +1,187 @@ -from __future__ import annotations - -import time - -import pytest -import httpx - -from node_wire_runtime.mcp_client.config import ( - AuthClientConfig, - AuthConfig, - AuthTokenConfig, - McpClientConfig, - McpServerConfig, -) -from node_wire_runtime.mcp_client.discovery import ( - AuthorizationServerMetadata, - DiscoveryResult, - ProtectedResourceMetadata, -) -from node_wire_runtime.mcp_client.exceptions import McpAudienceMismatch, McpTokenRefreshError -from node_wire_runtime.mcp_client.storage import ClientRegistration -from node_wire_runtime.mcp_client.token_manager import TokenManager -from node_wire_runtime.mcp_client.token_storage import ( - InMemoryTokenStore, - stored_from_oauth_response, -) - - -def _discovery() -> DiscoveryResult: - return DiscoveryResult( - mcp_server_url="https://mcp.example.com/mcp", - protected_resource=ProtectedResourceMetadata( - resource="https://mcp.example.com/mcp", - authorization_servers=("https://issuer.example",), - raw={}, - ), - authorization_server=AuthorizationServerMetadata( - issuer="https://issuer.example", - authorization_endpoint="https://issuer.example/authorize", - token_endpoint="https://issuer.example/token", - registration_endpoint=None, - scopes_supported=None, - raw={}, - ), - issuer="https://issuer.example", - ) - - -def _config() -> McpClientConfig: - return McpClientConfig( - server=McpServerConfig(url="https://mcp.example.com/mcp"), - auth=AuthConfig( - client=AuthClientConfig(id="cid", secret=""), - token=AuthTokenConfig(refresh_lead_seconds=60), - ), - ) - - -def _manager(*, store: InMemoryTokenStore | None = None) -> TokenManager: - reg = ClientRegistration( - issuer="https://issuer.example", - client_id="cid", - client_secret=None, - redirect_uris=("http://127.0.0.1:1/callback",), - token_endpoint_auth_method="none", - registered_at="", - ) - from node_wire_runtime.mcp_client.oauth_flow import AuthorizationCodeFlow - - flow = AuthorizationCodeFlow(_config(), discovery=_discovery(), registration=reg) - return TokenManager( - _config(), - user_id="alice", - token_store=store or InMemoryTokenStore(), - discovery=_discovery(), - registration=reg, - auth_flow=flow, - ) - - -@pytest.mark.asyncio -async def test_proactive_refresh_before_expiry() -> None: - store = InMemoryTokenStore() - mgr = _manager(store=store) - stored = stored_from_oauth_response( - user_id="alice", - mcp_server_url="https://mcp.example.com/mcp", - issuer="https://issuer.example", - access_token="old", - token_type="Bearer", - expires_in=30, - refresh_token="rt", - scope=None, - ) - stored.expires_at = time.time() + 30 - mgr.save_tokens(stored) - - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path.endswith("/token"): - return httpx.Response( - 200, - json={ - "access_token": "new", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "rt2", - }, - ) - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as client: - token = await mgr.get_bearer_token(http_client=client) - assert token == "new" - - -@pytest.mark.asyncio -async def test_refresh_invalid_grant_discards_and_raises() -> None: - store = InMemoryTokenStore() - mgr = _manager(store=store) - mgr.save_tokens( - stored_from_oauth_response( - user_id="alice", - mcp_server_url="https://mcp.example.com/mcp", - issuer="https://issuer.example", - access_token="old", - token_type="Bearer", - expires_in=1, - refresh_token="bad", - scope=None, - ) - ) - - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(400, json={"error": "invalid_grant"}) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as client: - with pytest.raises(McpTokenRefreshError): - await mgr.refresh_tokens(mgr.load_stored(), http_client=client) # type: ignore[arg-type] - - -@pytest.mark.asyncio -async def test_handle_mcp_403_forbidden() -> None: - mgr = _manager() - action = await mgr.handle_mcp_response(403, None) - assert action == "forbidden" - - -@pytest.mark.asyncio -async def test_handle_mcp_401_invalid_token_retries() -> None: - mgr = _manager() - mgr.save_tokens( - stored_from_oauth_response( - user_id="alice", - mcp_server_url="https://mcp.example.com/mcp", - issuer="https://issuer.example", - access_token="stale", - token_type="Bearer", - expires_in=3600, - refresh_token=None, - scope=None, - ) - ) - action = await mgr.handle_mcp_response( - 401, - 'Bearer error="invalid_token"', - ) - assert action == "retry" - assert mgr.load_stored() is not None - - -def test_jwt_audience_mismatch() -> None: - import jwt - - mgr = _manager() - token = jwt.encode( - {"aud": "https://other.example/mcp"}, - "test-secret-key-32-bytes-min!!", - algorithm="HS256", - ) - with pytest.raises(McpAudienceMismatch): - mgr.validate_access_token_audience(token) +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +import time + +import pytest +import httpx + +from node_wire_runtime.mcp_client.config import ( + AuthClientConfig, + AuthConfig, + AuthTokenConfig, + McpClientConfig, + McpServerConfig, +) +from node_wire_runtime.mcp_client.discovery import ( + AuthorizationServerMetadata, + DiscoveryResult, + ProtectedResourceMetadata, +) +from node_wire_runtime.mcp_client.exceptions import McpAudienceMismatch, McpTokenRefreshError +from node_wire_runtime.mcp_client.storage import ClientRegistration +from node_wire_runtime.mcp_client.token_manager import TokenManager +from node_wire_runtime.mcp_client.token_storage import ( + InMemoryTokenStore, + stored_from_oauth_response, +) + + +def _discovery() -> DiscoveryResult: + return DiscoveryResult( + mcp_server_url="https://mcp.example.com/mcp", + protected_resource=ProtectedResourceMetadata( + resource="https://mcp.example.com/mcp", + authorization_servers=("https://issuer.example",), + raw={}, + ), + authorization_server=AuthorizationServerMetadata( + issuer="https://issuer.example", + authorization_endpoint="https://issuer.example/authorize", + token_endpoint="https://issuer.example/token", + registration_endpoint=None, + scopes_supported=None, + raw={}, + ), + issuer="https://issuer.example", + ) + + +def _config() -> McpClientConfig: + return McpClientConfig( + server=McpServerConfig(url="https://mcp.example.com/mcp"), + auth=AuthConfig( + client=AuthClientConfig(id="cid", secret=""), + token=AuthTokenConfig(refresh_lead_seconds=60), + ), + ) + + +def _manager(*, store: InMemoryTokenStore | None = None) -> TokenManager: + reg = ClientRegistration( + issuer="https://issuer.example", + client_id="cid", + client_secret=None, + redirect_uris=("http://127.0.0.1:1/callback",), + token_endpoint_auth_method="none", + registered_at="", + ) + from node_wire_runtime.mcp_client.oauth_flow import AuthorizationCodeFlow + + flow = AuthorizationCodeFlow(_config(), discovery=_discovery(), registration=reg) + return TokenManager( + _config(), + user_id="alice", + token_store=store or InMemoryTokenStore(), + discovery=_discovery(), + registration=reg, + auth_flow=flow, + ) + + +@pytest.mark.asyncio +async def test_proactive_refresh_before_expiry() -> None: + store = InMemoryTokenStore() + mgr = _manager(store=store) + stored = stored_from_oauth_response( + user_id="alice", + mcp_server_url="https://mcp.example.com/mcp", + issuer="https://issuer.example", + access_token="old", + token_type="Bearer", + expires_in=30, + refresh_token="rt", + scope=None, + ) + stored.expires_at = time.time() + 30 + mgr.save_tokens(stored) + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/token"): + return httpx.Response( + 200, + json={ + "access_token": "new", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "rt2", + }, + ) + return httpx.Response(404) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as client: + token = await mgr.get_bearer_token(http_client=client) + assert token == "new" + + +@pytest.mark.asyncio +async def test_refresh_invalid_grant_discards_and_raises() -> None: + store = InMemoryTokenStore() + mgr = _manager(store=store) + mgr.save_tokens( + stored_from_oauth_response( + user_id="alice", + mcp_server_url="https://mcp.example.com/mcp", + issuer="https://issuer.example", + access_token="old", + token_type="Bearer", + expires_in=1, + refresh_token="bad", + scope=None, + ) + ) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(400, json={"error": "invalid_grant"}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as client: + with pytest.raises(McpTokenRefreshError): + await mgr.refresh_tokens(mgr.load_stored(), http_client=client) # type: ignore[arg-type] + + +@pytest.mark.asyncio +async def test_handle_mcp_403_forbidden() -> None: + mgr = _manager() + action = await mgr.handle_mcp_response(403, None) + assert action == "forbidden" + + +@pytest.mark.asyncio +async def test_handle_mcp_401_invalid_token_retries() -> None: + mgr = _manager() + mgr.save_tokens( + stored_from_oauth_response( + user_id="alice", + mcp_server_url="https://mcp.example.com/mcp", + issuer="https://issuer.example", + access_token="stale", + token_type="Bearer", + expires_in=3600, + refresh_token=None, + scope=None, + ) + ) + action = await mgr.handle_mcp_response( + 401, + 'Bearer error="invalid_token"', + ) + assert action == "retry" + assert mgr.load_stored() is not None + + +def test_jwt_audience_mismatch() -> None: + import jwt + + mgr = _manager() + token = jwt.encode( + {"aud": "https://other.example/mcp"}, + "test-secret-key-32-bytes-min!!", + algorithm="HS256", + ) + with pytest.raises(McpAudienceMismatch): + mgr.validate_access_token_audience(token) diff --git a/tests/test_mcp_transport.py b/tests/test_mcp_transport.py index 74784e2..1ae7521 100644 --- a/tests/test_mcp_transport.py +++ b/tests/test_mcp_transport.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# import pytest import httpx from unittest.mock import patch diff --git a/tests/test_network_bindings.py b/tests/test_network_bindings.py index 925b436..b62502a 100644 --- a/tests/test_network_bindings.py +++ b/tests/test_network_bindings.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch diff --git a/tests/test_payload_redaction.py b/tests/test_payload_redaction.py index 57f6065..14f2955 100644 --- a/tests/test_payload_redaction.py +++ b/tests/test_payload_redaction.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from __future__ import annotations import base64 diff --git a/tests/test_rest_app_import_env.py b/tests/test_rest_app_import_env.py index 066c852..68a288e 100644 --- a/tests/test_rest_app_import_env.py +++ b/tests/test_rest_app_import_env.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """Guardrails for REST app import during pytest collection. Requires ``conftest`` env + ``connectors_for_tests.yaml`` so enabled connectors match diff --git a/tests/test_rest_body_limit.py b/tests/test_rest_body_limit.py index 5a2396a..2590416 100644 --- a/tests/test_rest_body_limit.py +++ b/tests/test_rest_body_limit.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from __future__ import annotations from unittest.mock import AsyncMock, MagicMock diff --git a/tests/test_rest_identity_key.py b/tests/test_rest_identity_key.py index ab4ad1a..eace743 100644 --- a/tests/test_rest_identity_key.py +++ b/tests/test_rest_identity_key.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from __future__ import annotations import hashlib diff --git a/tests/test_rest_rate_limit_enforcement.py b/tests/test_rest_rate_limit_enforcement.py index f9a8ce2..c0ca6e3 100644 --- a/tests/test_rest_rate_limit_enforcement.py +++ b/tests/test_rest_rate_limit_enforcement.py @@ -1,175 +1,179 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock, patch - -from fastapi.testclient import TestClient - -from bindings.rest_api import app as rest_app_module -from bindings.rest_api.app import app, get_factory -from bindings.rest_api.rate_limit import InMemoryRateLimiter -from node_wire_runtime.models import ConnectorResponse - - -def _stub_connector() -> MagicMock: - connector = MagicMock() - connector.run = AsyncMock( - return_value=ConnectorResponse(success=True, data={"ok": True}, trace_id="t-limit") - ) - return connector - - -def _make_client(monkeypatch) -> tuple[TestClient, MagicMock]: - monkeypatch.setenv("NW_REST_RATE_LIMIT_ENABLED", "true") - monkeypatch.setenv("NW_REST_RATE_LIMIT_MAX_REQUESTS", "2") - monkeypatch.setenv("NW_REST_RATE_LIMIT_WINDOW_SECONDS", "60") - monkeypatch.setattr(rest_app_module, "_rate_limiter", None) - monkeypatch.setattr(rest_app_module, "_rate_limiter_cfg", None) - - mock_factory = MagicMock() - mock_factory.get_for_protocol.return_value = _stub_connector() - app.dependency_overrides[get_factory] = lambda: mock_factory - return TestClient(app), mock_factory - - -def test_rest_rate_limit_allows_under_threshold(monkeypatch) -> None: - client, _ = _make_client(monkeypatch) - try: - first = client.post( - "/connectors/http_generic/request", - json={"method": "GET", "url": "https://example.com"}, - headers={"X-API-Key": "tenant-a"}, - ) - second = client.post( - "/connectors/http_generic/request", - json={"method": "GET", "url": "https://example.com"}, - headers={"X-API-Key": "tenant-a"}, - ) - finally: - app.dependency_overrides.clear() - assert first.status_code == 200 - assert second.status_code == 200 - - -def test_rest_rate_limit_returns_429_and_retry_after(monkeypatch) -> None: - client, _ = _make_client(monkeypatch) - try: - client.post( - "/connectors/http_generic/request", - json={"method": "GET", "url": "https://example.com"}, - headers={"X-API-Key": "tenant-a"}, - ) - client.post( - "/connectors/http_generic/request", - json={"method": "GET", "url": "https://example.com"}, - headers={"X-API-Key": "tenant-a"}, - ) - third = client.post( - "/connectors/http_generic/request", - json={"method": "GET", "url": "https://example.com"}, - headers={"X-API-Key": "tenant-a"}, - ) - finally: - app.dependency_overrides.clear() - - assert third.status_code == 429 - assert third.json()["detail"] == "Rate limit exceeded" - retry_after = third.headers.get("Retry-After") - assert retry_after is not None - assert int(retry_after) >= 1 - - -def test_rest_rate_limit_isolated_by_identity(monkeypatch) -> None: - monkeypatch.setenv("NW_REST_RATE_LIMIT_ENABLED", "true") - monkeypatch.setenv("NW_REST_RATE_LIMIT_MAX_REQUESTS", "1") - monkeypatch.setenv("NW_REST_RATE_LIMIT_WINDOW_SECONDS", "60") - monkeypatch.setattr(rest_app_module, "_rate_limiter", None) - monkeypatch.setattr(rest_app_module, "_rate_limiter_cfg", None) - - mock_factory = MagicMock() - mock_factory.get_for_protocol.return_value = _stub_connector() - app.dependency_overrides[get_factory] = lambda: mock_factory - - try: - client = TestClient(app) - first = client.post( - "/connectors/http_generic/request", - json={"method": "GET", "url": "https://example.com"}, - headers={"X-API-Key": "tenant-a"}, - ) - second = client.post( - "/connectors/http_generic/request", - json={"method": "GET", "url": "https://example.com"}, - headers={"X-API-Key": "tenant-b"}, - ) - finally: - app.dependency_overrides.clear() - - assert first.status_code == 200 - assert second.status_code == 200 - - -def test_rate_limiter_evicts_when_max_keys_exceeded() -> None: - limiter = InMemoryRateLimiter( - max_requests=10, - window_seconds=60, - max_tracked_keys=2, - key_ttl_seconds=3600, - ) - assert limiter.consume("key-a").allowed is True - assert limiter.consume("key-b").allowed is True - assert limiter.tracked_key_count == 2 - - assert limiter.consume("key-c").allowed is True - assert limiter.tracked_key_count == 2 - assert "key-a" not in limiter._buckets # noqa: SLF001 - - assert limiter.consume("key-a").allowed is True - assert limiter.tracked_key_count == 2 - - -def test_rate_limiter_evicts_idle_keys_after_ttl() -> None: - limiter = InMemoryRateLimiter( - max_requests=10, - window_seconds=60, - max_tracked_keys=10, - key_ttl_seconds=1, - ) - times = iter([100.0, 103.0]) - with patch("bindings.rest_api.rate_limit.monotonic", side_effect=lambda: next(times)): - assert limiter.consume("idle-key").allowed is True - assert limiter.tracked_key_count == 1 - assert limiter.consume("fresh-key").allowed is True - assert limiter.tracked_key_count == 1 - assert "idle-key" not in limiter._buckets # noqa: SLF001 - assert "fresh-key" in limiter._buckets # noqa: SLF001 - - -def test_rest_rate_limit_ignores_spoofed_xff_when_proxy_hops_zero(monkeypatch) -> None: - monkeypatch.setenv("NW_REST_RATE_LIMIT_ENABLED", "true") - monkeypatch.setenv("NW_REST_RATE_LIMIT_MAX_REQUESTS", "1") - monkeypatch.setenv("NW_REST_RATE_LIMIT_WINDOW_SECONDS", "60") - monkeypatch.setenv("NW_REST_TRUSTED_PROXY_HOPS", "0") - monkeypatch.setattr(rest_app_module, "_rate_limiter", None) - monkeypatch.setattr(rest_app_module, "_rate_limiter_cfg", None) - - mock_factory = MagicMock() - mock_factory.get_for_protocol.return_value = _stub_connector() - app.dependency_overrides[get_factory] = lambda: mock_factory - - try: - client = TestClient(app) - first = client.post( - "/connectors/http_generic/request", - json={"method": "GET", "url": "https://example.com"}, - headers={"X-Forwarded-For": "203.0.113.1"}, - ) - second = client.post( - "/connectors/http_generic/request", - json={"method": "GET", "url": "https://example.com"}, - headers={"X-Forwarded-For": "203.0.113.2"}, - ) - finally: - app.dependency_overrides.clear() - - assert first.status_code == 200 - assert second.status_code == 429 +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi.testclient import TestClient + +from bindings.rest_api import app as rest_app_module +from bindings.rest_api.app import app, get_factory +from bindings.rest_api.rate_limit import InMemoryRateLimiter +from node_wire_runtime.models import ConnectorResponse + + +def _stub_connector() -> MagicMock: + connector = MagicMock() + connector.run = AsyncMock( + return_value=ConnectorResponse(success=True, data={"ok": True}, trace_id="t-limit") + ) + return connector + + +def _make_client(monkeypatch) -> tuple[TestClient, MagicMock]: + monkeypatch.setenv("NW_REST_RATE_LIMIT_ENABLED", "true") + monkeypatch.setenv("NW_REST_RATE_LIMIT_MAX_REQUESTS", "2") + monkeypatch.setenv("NW_REST_RATE_LIMIT_WINDOW_SECONDS", "60") + monkeypatch.setattr(rest_app_module, "_rate_limiter", None) + monkeypatch.setattr(rest_app_module, "_rate_limiter_cfg", None) + + mock_factory = MagicMock() + mock_factory.get_for_protocol.return_value = _stub_connector() + app.dependency_overrides[get_factory] = lambda: mock_factory + return TestClient(app), mock_factory + + +def test_rest_rate_limit_allows_under_threshold(monkeypatch) -> None: + client, _ = _make_client(monkeypatch) + try: + first = client.post( + "/connectors/http_generic/request", + json={"method": "GET", "url": "https://example.com"}, + headers={"X-API-Key": "tenant-a"}, + ) + second = client.post( + "/connectors/http_generic/request", + json={"method": "GET", "url": "https://example.com"}, + headers={"X-API-Key": "tenant-a"}, + ) + finally: + app.dependency_overrides.clear() + assert first.status_code == 200 + assert second.status_code == 200 + + +def test_rest_rate_limit_returns_429_and_retry_after(monkeypatch) -> None: + client, _ = _make_client(monkeypatch) + try: + client.post( + "/connectors/http_generic/request", + json={"method": "GET", "url": "https://example.com"}, + headers={"X-API-Key": "tenant-a"}, + ) + client.post( + "/connectors/http_generic/request", + json={"method": "GET", "url": "https://example.com"}, + headers={"X-API-Key": "tenant-a"}, + ) + third = client.post( + "/connectors/http_generic/request", + json={"method": "GET", "url": "https://example.com"}, + headers={"X-API-Key": "tenant-a"}, + ) + finally: + app.dependency_overrides.clear() + + assert third.status_code == 429 + assert third.json()["detail"] == "Rate limit exceeded" + retry_after = third.headers.get("Retry-After") + assert retry_after is not None + assert int(retry_after) >= 1 + + +def test_rest_rate_limit_isolated_by_identity(monkeypatch) -> None: + monkeypatch.setenv("NW_REST_RATE_LIMIT_ENABLED", "true") + monkeypatch.setenv("NW_REST_RATE_LIMIT_MAX_REQUESTS", "1") + monkeypatch.setenv("NW_REST_RATE_LIMIT_WINDOW_SECONDS", "60") + monkeypatch.setattr(rest_app_module, "_rate_limiter", None) + monkeypatch.setattr(rest_app_module, "_rate_limiter_cfg", None) + + mock_factory = MagicMock() + mock_factory.get_for_protocol.return_value = _stub_connector() + app.dependency_overrides[get_factory] = lambda: mock_factory + + try: + client = TestClient(app) + first = client.post( + "/connectors/http_generic/request", + json={"method": "GET", "url": "https://example.com"}, + headers={"X-API-Key": "tenant-a"}, + ) + second = client.post( + "/connectors/http_generic/request", + json={"method": "GET", "url": "https://example.com"}, + headers={"X-API-Key": "tenant-b"}, + ) + finally: + app.dependency_overrides.clear() + + assert first.status_code == 200 + assert second.status_code == 200 + + +def test_rate_limiter_evicts_when_max_keys_exceeded() -> None: + limiter = InMemoryRateLimiter( + max_requests=10, + window_seconds=60, + max_tracked_keys=2, + key_ttl_seconds=3600, + ) + assert limiter.consume("key-a").allowed is True + assert limiter.consume("key-b").allowed is True + assert limiter.tracked_key_count == 2 + + assert limiter.consume("key-c").allowed is True + assert limiter.tracked_key_count == 2 + assert "key-a" not in limiter._buckets # noqa: SLF001 + + assert limiter.consume("key-a").allowed is True + assert limiter.tracked_key_count == 2 + + +def test_rate_limiter_evicts_idle_keys_after_ttl() -> None: + limiter = InMemoryRateLimiter( + max_requests=10, + window_seconds=60, + max_tracked_keys=10, + key_ttl_seconds=1, + ) + times = iter([100.0, 103.0]) + with patch("bindings.rest_api.rate_limit.monotonic", side_effect=lambda: next(times)): + assert limiter.consume("idle-key").allowed is True + assert limiter.tracked_key_count == 1 + assert limiter.consume("fresh-key").allowed is True + assert limiter.tracked_key_count == 1 + assert "idle-key" not in limiter._buckets # noqa: SLF001 + assert "fresh-key" in limiter._buckets # noqa: SLF001 + + +def test_rest_rate_limit_ignores_spoofed_xff_when_proxy_hops_zero(monkeypatch) -> None: + monkeypatch.setenv("NW_REST_RATE_LIMIT_ENABLED", "true") + monkeypatch.setenv("NW_REST_RATE_LIMIT_MAX_REQUESTS", "1") + monkeypatch.setenv("NW_REST_RATE_LIMIT_WINDOW_SECONDS", "60") + monkeypatch.setenv("NW_REST_TRUSTED_PROXY_HOPS", "0") + monkeypatch.setattr(rest_app_module, "_rate_limiter", None) + monkeypatch.setattr(rest_app_module, "_rate_limiter_cfg", None) + + mock_factory = MagicMock() + mock_factory.get_for_protocol.return_value = _stub_connector() + app.dependency_overrides[get_factory] = lambda: mock_factory + + try: + client = TestClient(app) + first = client.post( + "/connectors/http_generic/request", + json={"method": "GET", "url": "https://example.com"}, + headers={"X-Forwarded-For": "203.0.113.1"}, + ) + second = client.post( + "/connectors/http_generic/request", + json={"method": "GET", "url": "https://example.com"}, + headers={"X-Forwarded-For": "203.0.113.2"}, + ) + finally: + app.dependency_overrides.clear() + + assert first.status_code == 200 + assert second.status_code == 429 diff --git a/tests/test_runtime_resilience.py b/tests/test_runtime_resilience.py index 764bd28..076b87d 100644 --- a/tests/test_runtime_resilience.py +++ b/tests/test_runtime_resilience.py @@ -1,229 +1,233 @@ -from __future__ import annotations - -import asyncio - -import pytest -from pydantic import BaseModel -from pybreaker import CircuitBreaker, CircuitBreakerError - -from node_wire_runtime import BaseConnector, ErrorCategory, ErrorMapper, nw_action -from node_wire_runtime.resilience import _resolve_breaker, with_resilience - - -class RetryableTestError(Exception): - pass - - -class FatalTestError(Exception): - pass - - -class RetryInput(BaseModel): - action: str = "retry" - value: int = 1 - - -class RetryOutput(BaseModel): - attempts: int - - -class FlakyConnector(BaseConnector): - connector_id = "test_flaky_resilience" - output_model = RetryOutput - - def __init__(self) -> None: - super().__init__() - self.calls_by_tenant: dict[str, int] = {} - self.failures_by_tenant: dict[str, int] = {} - - @nw_action("retry") - async def retry(self, params: RetryInput, *, trace_id: str) -> RetryOutput: - tenant_key = trace_id.split(":", maxsplit=1)[0] - self.calls_by_tenant[tenant_key] = self.calls_by_tenant.get(tenant_key, 0) + 1 - failures = self.failures_by_tenant.get(tenant_key, 0) - if failures > 0: - self.failures_by_tenant[tenant_key] = failures - 1 - raise RetryableTestError(f"retryable failure for {tenant_key}") - return RetryOutput(attempts=self.calls_by_tenant[tenant_key]) - - async def run_for_tenant(self, tenant_id: str) -> object: - original_internal_execute = self.internal_execute - - async def _tagged_internal_execute(params: object, *, trace_id: str) -> object: - return await original_internal_execute(params, trace_id=f"{tenant_id}:{trace_id}") - - self.internal_execute = _tagged_internal_execute # type: ignore[method-assign] - try: - return await self.run({"action": "retry", "value": 1}, tenant_id=tenant_id) - finally: - self.internal_execute = original_internal_execute # type: ignore[method-assign] - - -@pytest.fixture(autouse=True) -def reset_error_mapper_registry() -> None: - original = dict(ErrorMapper._registry) - try: - ErrorMapper._registry.clear() - ErrorMapper.register(RetryableTestError, ErrorCategory.RETRYABLE, code="RETRYABLE_TEST") - yield - finally: - ErrorMapper._registry.clear() - ErrorMapper._registry.update(original) - - -def test_with_resilience_retries_retryable_errors_until_success() -> None: - connector = FlakyConnector() - connector.failures_by_tenant["tenant-a"] = 2 - - response = asyncio.run(connector.run_for_tenant("tenant-a")) - - assert response.success is True - assert response.data == {"attempts": 3} - assert connector.calls_by_tenant["tenant-a"] == 3 - - -def test_tenant_breaker_state_is_isolated_across_shared_connector_instance() -> None: - connector = FlakyConnector() - connector._breaker_for_tenant("tenant-a").open() - - first = asyncio.run(connector.run_for_tenant("tenant-a")) - other_tenant = asyncio.run(connector.run_for_tenant("tenant-b")) - - assert first.success is False - assert first.error_code == "CircuitBreakerError" - assert first.error_category == ErrorCategory.FATAL - assert other_tenant.success is True - assert other_tenant.data == {"attempts": 1} - - -def test_breaker_cache_uses_distinct_keys_per_tenant() -> None: - connector = FlakyConnector() - - default_breaker = connector._breaker_for_tenant(None) - tenant_a_breaker = connector._breaker_for_tenant("tenant-a") - tenant_b_breaker = connector._breaker_for_tenant("tenant-b") - - assert default_breaker is connector._breaker_for_tenant(None) - assert tenant_a_breaker is connector._breaker_for_tenant("tenant-a") - assert tenant_a_breaker is not tenant_b_breaker - assert default_breaker is not tenant_a_breaker - - -def test_open_breaker_rejects_calls_immediately() -> None: - connector = FlakyConnector() - breaker = connector._breaker_for_tenant("tenant-a") - breaker.open() - - response = asyncio.run(connector.run_for_tenant("tenant-a")) - - assert response.success is False - assert response.error_code == "CircuitBreakerError" - - -def test_circuit_breaker_error_defaults_to_fatal_mapping() -> None: - mapped = ErrorMapper.resolve(CircuitBreakerError("open")) - - assert mapped.code == "CircuitBreakerError" - assert mapped.category == ErrorCategory.FATAL - - -def test_resolve_breaker_returns_same_instance_for_circuit_breaker() -> None: - cb = CircuitBreaker() - assert _resolve_breaker(cb) is cb - - -def test_resolve_breaker_invokes_factory_callable() -> None: - created = CircuitBreaker() - - def factory() -> CircuitBreaker: - return created - - assert _resolve_breaker(factory) is created - - -def test_resolve_breaker_resolved_object_has_state() -> None: - cb = CircuitBreaker() - resolved = _resolve_breaker(cb) - assert hasattr(resolved, "state") - assert resolved.state.name in ("closed", "open", "half-open") - - -def test_with_resilience_accepts_concrete_circuit_breaker_instance() -> None: - breaker = CircuitBreaker() - - @with_resilience(breaker) - async def succeed(*, trace_id: str = "t") -> str: - return "ok" - - assert asyncio.run(succeed(trace_id="x")) == "ok" - - -def test_fatal_errors_do_not_retry() -> None: - class FatalConnector(BaseConnector): - connector_id = "test_fatal_resilience" - output_model = RetryOutput - - @nw_action("retry") - async def retry(self, params: RetryInput, *, trace_id: str) -> RetryOutput: - raise FatalTestError("fatal") - - connector = FatalConnector() - response = asyncio.run(connector.run({"action": "retry", "value": 1}, tenant_id="tenant-a")) - - assert response.success is False - assert response.error_code == "FatalTestError" - assert response.error_category == ErrorCategory.FATAL - - -def test_with_resilience_opens_breaker_after_repeated_failures() -> None: - breaker = CircuitBreaker(fail_max=2, reset_timeout=30) - - @with_resilience(breaker, max_attempts=1) - async def fail(*, trace_id: str = "t") -> str: - raise RetryableTestError("boom") - - with pytest.raises(RetryableTestError): - asyncio.run(fail(trace_id="t1")) - - with pytest.raises(CircuitBreakerError): - asyncio.run(fail(trace_id="t2")) - - assert breaker.state.name == "open" - - with pytest.raises(CircuitBreakerError): - asyncio.run(fail(trace_id="t3")) - - -def test_pybreaker_private_contract_still_present() -> None: - """Guard for Q-2: the resilience adapter depends on pybreaker private methods. - - pybreaker exposes no clean asyncio public API, so ``_run_through_breaker`` - deliberately uses ``CircuitBreakerState._handle_error`` / ``_handle_success`` - and pins ``pybreaker<2.0.0``. If a future upgrade removes these, this test - fails loudly in CI instead of the breaker silently mis-counting failures. - """ - from pybreaker import CircuitBreakerState - - assert hasattr(CircuitBreakerState, "_handle_error") - assert hasattr(CircuitBreakerState, "_handle_success") - - -def test_with_resilience_success_resets_failure_counter() -> None: - """A successful call after a failure resets the breaker's failure counter.""" - breaker = CircuitBreaker(fail_max=3, reset_timeout=30) - state = {"fail": True} - - @with_resilience(breaker, max_attempts=1) - async def sometimes(*, trace_id: str = "t") -> str: - if state["fail"]: - raise RetryableTestError("boom") - return "ok" - - with pytest.raises(RetryableTestError): - asyncio.run(sometimes(trace_id="t1")) - assert breaker.fail_counter == 1 - - state["fail"] = False - assert asyncio.run(sometimes(trace_id="t2")) == "ok" - assert breaker.fail_counter == 0 - assert breaker.state.name == "closed" +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +import asyncio + +import pytest +from pydantic import BaseModel +from pybreaker import CircuitBreaker, CircuitBreakerError + +from node_wire_runtime import BaseConnector, ErrorCategory, ErrorMapper, nw_action +from node_wire_runtime.resilience import _resolve_breaker, with_resilience + + +class RetryableTestError(Exception): + pass + + +class FatalTestError(Exception): + pass + + +class RetryInput(BaseModel): + action: str = "retry" + value: int = 1 + + +class RetryOutput(BaseModel): + attempts: int + + +class FlakyConnector(BaseConnector): + connector_id = "test_flaky_resilience" + output_model = RetryOutput + + def __init__(self) -> None: + super().__init__() + self.calls_by_tenant: dict[str, int] = {} + self.failures_by_tenant: dict[str, int] = {} + + @nw_action("retry") + async def retry(self, params: RetryInput, *, trace_id: str) -> RetryOutput: + tenant_key = trace_id.split(":", maxsplit=1)[0] + self.calls_by_tenant[tenant_key] = self.calls_by_tenant.get(tenant_key, 0) + 1 + failures = self.failures_by_tenant.get(tenant_key, 0) + if failures > 0: + self.failures_by_tenant[tenant_key] = failures - 1 + raise RetryableTestError(f"retryable failure for {tenant_key}") + return RetryOutput(attempts=self.calls_by_tenant[tenant_key]) + + async def run_for_tenant(self, tenant_id: str) -> object: + original_internal_execute = self.internal_execute + + async def _tagged_internal_execute(params: object, *, trace_id: str) -> object: + return await original_internal_execute(params, trace_id=f"{tenant_id}:{trace_id}") + + self.internal_execute = _tagged_internal_execute # type: ignore[method-assign] + try: + return await self.run({"action": "retry", "value": 1}, tenant_id=tenant_id) + finally: + self.internal_execute = original_internal_execute # type: ignore[method-assign] + + +@pytest.fixture(autouse=True) +def reset_error_mapper_registry() -> None: + original = dict(ErrorMapper._registry) + try: + ErrorMapper._registry.clear() + ErrorMapper.register(RetryableTestError, ErrorCategory.RETRYABLE, code="RETRYABLE_TEST") + yield + finally: + ErrorMapper._registry.clear() + ErrorMapper._registry.update(original) + + +def test_with_resilience_retries_retryable_errors_until_success() -> None: + connector = FlakyConnector() + connector.failures_by_tenant["tenant-a"] = 2 + + response = asyncio.run(connector.run_for_tenant("tenant-a")) + + assert response.success is True + assert response.data == {"attempts": 3} + assert connector.calls_by_tenant["tenant-a"] == 3 + + +def test_tenant_breaker_state_is_isolated_across_shared_connector_instance() -> None: + connector = FlakyConnector() + connector._breaker_for_tenant("tenant-a").open() + + first = asyncio.run(connector.run_for_tenant("tenant-a")) + other_tenant = asyncio.run(connector.run_for_tenant("tenant-b")) + + assert first.success is False + assert first.error_code == "CircuitBreakerError" + assert first.error_category == ErrorCategory.FATAL + assert other_tenant.success is True + assert other_tenant.data == {"attempts": 1} + + +def test_breaker_cache_uses_distinct_keys_per_tenant() -> None: + connector = FlakyConnector() + + default_breaker = connector._breaker_for_tenant(None) + tenant_a_breaker = connector._breaker_for_tenant("tenant-a") + tenant_b_breaker = connector._breaker_for_tenant("tenant-b") + + assert default_breaker is connector._breaker_for_tenant(None) + assert tenant_a_breaker is connector._breaker_for_tenant("tenant-a") + assert tenant_a_breaker is not tenant_b_breaker + assert default_breaker is not tenant_a_breaker + + +def test_open_breaker_rejects_calls_immediately() -> None: + connector = FlakyConnector() + breaker = connector._breaker_for_tenant("tenant-a") + breaker.open() + + response = asyncio.run(connector.run_for_tenant("tenant-a")) + + assert response.success is False + assert response.error_code == "CircuitBreakerError" + + +def test_circuit_breaker_error_defaults_to_fatal_mapping() -> None: + mapped = ErrorMapper.resolve(CircuitBreakerError("open")) + + assert mapped.code == "CircuitBreakerError" + assert mapped.category == ErrorCategory.FATAL + + +def test_resolve_breaker_returns_same_instance_for_circuit_breaker() -> None: + cb = CircuitBreaker() + assert _resolve_breaker(cb) is cb + + +def test_resolve_breaker_invokes_factory_callable() -> None: + created = CircuitBreaker() + + def factory() -> CircuitBreaker: + return created + + assert _resolve_breaker(factory) is created + + +def test_resolve_breaker_resolved_object_has_state() -> None: + cb = CircuitBreaker() + resolved = _resolve_breaker(cb) + assert hasattr(resolved, "state") + assert resolved.state.name in ("closed", "open", "half-open") + + +def test_with_resilience_accepts_concrete_circuit_breaker_instance() -> None: + breaker = CircuitBreaker() + + @with_resilience(breaker) + async def succeed(*, trace_id: str = "t") -> str: + return "ok" + + assert asyncio.run(succeed(trace_id="x")) == "ok" + + +def test_fatal_errors_do_not_retry() -> None: + class FatalConnector(BaseConnector): + connector_id = "test_fatal_resilience" + output_model = RetryOutput + + @nw_action("retry") + async def retry(self, params: RetryInput, *, trace_id: str) -> RetryOutput: + raise FatalTestError("fatal") + + connector = FatalConnector() + response = asyncio.run(connector.run({"action": "retry", "value": 1}, tenant_id="tenant-a")) + + assert response.success is False + assert response.error_code == "FatalTestError" + assert response.error_category == ErrorCategory.FATAL + + +def test_with_resilience_opens_breaker_after_repeated_failures() -> None: + breaker = CircuitBreaker(fail_max=2, reset_timeout=30) + + @with_resilience(breaker, max_attempts=1) + async def fail(*, trace_id: str = "t") -> str: + raise RetryableTestError("boom") + + with pytest.raises(RetryableTestError): + asyncio.run(fail(trace_id="t1")) + + with pytest.raises(CircuitBreakerError): + asyncio.run(fail(trace_id="t2")) + + assert breaker.state.name == "open" + + with pytest.raises(CircuitBreakerError): + asyncio.run(fail(trace_id="t3")) + + +def test_pybreaker_private_contract_still_present() -> None: + """Guard for Q-2: the resilience adapter depends on pybreaker private methods. + + pybreaker exposes no clean asyncio public API, so ``_run_through_breaker`` + deliberately uses ``CircuitBreakerState._handle_error`` / ``_handle_success`` + and pins ``pybreaker<2.0.0``. If a future upgrade removes these, this test + fails loudly in CI instead of the breaker silently mis-counting failures. + """ + from pybreaker import CircuitBreakerState + + assert hasattr(CircuitBreakerState, "_handle_error") + assert hasattr(CircuitBreakerState, "_handle_success") + + +def test_with_resilience_success_resets_failure_counter() -> None: + """A successful call after a failure resets the breaker's failure counter.""" + breaker = CircuitBreaker(fail_max=3, reset_timeout=30) + state = {"fail": True} + + @with_resilience(breaker, max_attempts=1) + async def sometimes(*, trace_id: str = "t") -> str: + if state["fail"]: + raise RetryableTestError("boom") + return "ok" + + with pytest.raises(RetryableTestError): + asyncio.run(sometimes(trace_id="t1")) + assert breaker.fail_counter == 1 + + state["fail"] = False + assert asyncio.run(sometimes(trace_id="t2")) == "ok" + assert breaker.fail_counter == 0 + assert breaker.state.name == "closed" diff --git a/tests/test_salesforce.py b/tests/test_salesforce.py index 7cefb4d..64c8e0e 100644 --- a/tests/test_salesforce.py +++ b/tests/test_salesforce.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch diff --git a/tests/test_scope_policy_transport.py b/tests/test_scope_policy_transport.py index 3aa928c..a036aab 100644 --- a/tests/test_scope_policy_transport.py +++ b/tests/test_scope_policy_transport.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from __future__ import annotations import asyncio diff --git a/tests/test_slack_connector.py b/tests/test_slack_connector.py index e629a0b..53bc732 100644 --- a/tests/test_slack_connector.py +++ b/tests/test_slack_connector.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# """ Unit tests for the Node-Wire Slack connector. diff --git a/tests/test_stripe.py b/tests/test_stripe.py index 73fcd6a..6f077c3 100644 --- a/tests/test_stripe.py +++ b/tests/test_stripe.py @@ -1,3 +1,7 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# from __future__ import annotations from unittest.mock import MagicMock, patch diff --git a/uv.lock b/uv.lock index 471546e..c333e9d 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,9 @@ resolution-markers = [ "python_full_version < '3.13'", ] +[manifest] +overrides = [{ name = "lxml", specifier = ">=6.1.0" }] + [[package]] name = "aiohappyeyeballs" version = "2.6.2" @@ -19,7 +22,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.0" +version = "3.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -31,108 +34,108 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/47/7727bfe8db93f8835a001bd4359d8480cc68d1259b8bce334668f8be97bd/aiohttp-3.14.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:54bf3522d6f7351e55f89a62d5c2bf138ad557b031670266c5df604ae88e0b5a", size = 759147, upload-time = "2026-06-01T19:37:12.918Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f2/cd3fedff6fade73d71df9ec908c210cec518ef90fd00289250684b90aecf/aiohttp-3.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0746d9fb0ac4fdef643a84494efe3f06d50335dd8c7a530228b86448aae0a803", size = 513705, upload-time = "2026-06-01T19:37:14.633Z" }, - { url = "https://files.pythonhosted.org/packages/5a/fe/49746b6b610144a06323bebd8e1211a390310d8c69b98dd6d52df341bc3e/aiohttp-3.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f3a96b6d39a4872222beee72e1df41d2ff886ae96152cf3e757ef8c5673ef0e", size = 509627, upload-time = "2026-06-01T19:37:16.385Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3f/28f2f6cf3d5c0e7b01b27140d0e7873fd11fb341169ad3ce78ad04aba628/aiohttp-3.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d336820adbb914debbc90a1d8c1bfc4bea55996aecf64866a989d35d1f9fd903", size = 1769293, upload-time = "2026-06-01T19:37:18.067Z" }, - { url = "https://files.pythonhosted.org/packages/97/6f/2e5f1b525d5474b12b3c60abf733a755845f3bceff21542081ada515f837/aiohttp-3.14.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:71b2604c9bfc1b115547d63a094d5244b3f02799833513a99a68aaa7b167c4cb", size = 1732363, upload-time = "2026-06-01T19:37:20.138Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ce/596120faa85ca7b19cd061e3f2f3be23aa8f11a0aedf9191db9e0da1bd76/aiohttp-3.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:610d68800435903e303ca0542b9d3e4eb72a12ff33a6d471a070c1d81eebd3c2", size = 1840375, upload-time = "2026-06-01T19:37:22.104Z" }, - { url = "https://files.pythonhosted.org/packages/72/3c/a7ffe05a757a4a7867643da69357ec41f506879fbd1b231d2ed90af246b2/aiohttp-3.14.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:514db9a79337068981ee2137310283a07b4b885c584991097a91a4da419bcb81", size = 1921484, upload-time = "2026-06-01T19:37:24.068Z" }, - { url = "https://files.pythonhosted.org/packages/93/fa/2c861170bbd4a491de93a69e081db1d971092569e0d593a98ef62c384dc1/aiohttp-3.14.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c452d17eeb95d563fc8b936f3050301dbd1d268126c4632d8b70ede9696202ee", size = 1774153, upload-time = "2026-06-01T19:37:26.256Z" }, - { url = "https://files.pythonhosted.org/packages/9d/da/1d2f5a165f47ec9b1f69d37b8b977fdc4d501aa72ffb7930db27bb9e49ea/aiohttp-3.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed94a81506e3d1bdbad5108f497a58f2a2354aedb4ca314d5326f07d1fd1ac2d", size = 1632569, upload-time = "2026-06-01T19:37:28.192Z" }, - { url = "https://files.pythonhosted.org/packages/46/1d/7a6e295c4257252f70f69e90864fdad74b6a1293054fb3f9e65a15de6d63/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1394dce36e0f0d260ac0b555a654de19cb989f3c1b8bdd24f505314dfea18a00", size = 1740325, upload-time = "2026-06-01T19:37:30.08Z" }, - { url = "https://files.pythonhosted.org/packages/f1/7e/e1899b1ca3ec62f1eab2a5cbde14039b97493f7f53eb88d9b668562ffa8d/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d1467d1e7b48a73ca7237e0ee4335f3d02b923dbc27b82fd254bc301c97d4026", size = 1748691, upload-time = "2026-06-01T19:37:32.211Z" }, - { url = "https://files.pythonhosted.org/packages/ec/54/4e6b61c1fe7d3433f82bcc6bd7e4d7c683a742a10c9b12a025fd3695c047/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6a5f3532125233c261cf61f32df4059cfcf482eb793c7d3db8452e3142028b86", size = 1814477, upload-time = "2026-06-01T19:37:34.173Z" }, - { url = "https://files.pythonhosted.org/packages/9c/38/86fd51be2e08d8e45c83d879d255f10391903cd9fe2a16512f7591a15873/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3ea81eb518a2ecb319d8ec6d1424a37c773f6634bd87d6985eb606b2faac419f", size = 1623393, upload-time = "2026-06-01T19:37:36.281Z" }, - { url = "https://files.pythonhosted.org/packages/78/49/466e947a42a88ee23c486d036e7e5d1b097f1bafd8084ad9c9a0a92f0f43/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:32e735c3182de7b64f6941a4ede48b38c7f47d9437bd615dd30b5bda8fa1bc93", size = 1824097, upload-time = "2026-06-01T19:37:38.421Z" }, - { url = "https://files.pythonhosted.org/packages/f3/89/35f3410bc284682338a1be6b6ea0c5abfa05f063942cfaa9256608440434/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c21ca9a1c63d4509158f478aeb9d02914dcc52adc68d1bc9dee2452284ee5996", size = 1764790, upload-time = "2026-06-01T19:37:40.755Z" }, - { url = "https://files.pythonhosted.org/packages/42/80/2d4291bd5724d3d17e5951aff5a3e02281483fb47295f0788276ee66cd73/aiohttp-3.14.0-cp311-cp311-win32.whl", hash = "sha256:19ca5fc84130675ba11c6ca5c7da5cb65f7bf8a32cdd2b616bf49cd334688aae", size = 454176, upload-time = "2026-06-01T19:37:42.837Z" }, - { url = "https://files.pythonhosted.org/packages/59/ed/41d0ad4f6ececffc32bdf1f7b494e5498f7ca5c849ea2e3cc9bbd1668251/aiohttp-3.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:d488e6e9d3bb8ba5ae7066d5be885ae9670eba021b8c6ccb9a3a568e6b19d6e5", size = 479334, upload-time = "2026-06-01T19:37:44.776Z" }, - { url = "https://files.pythonhosted.org/packages/d1/86/c0b5e305c770053f8c3d069bb52b8196917ba91949d1962d52eb307fb0d2/aiohttp-3.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:8b93618102caf12801638a01a2b478a55410ddd71bd41cfaf6f707953a49ac43", size = 450262, upload-time = "2026-06-01T19:37:46.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/97/2b6889bfb6b6847520d50d95eb8c4307a45e28aaca39faf4a9454b3d1b2f/aiohttp-3.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b29518c9c2ec7e373e68259206a137c7f4f5439c58baaec4b5ab3ab799850a4e", size = 750194, upload-time = "2026-06-01T19:37:48.164Z" }, - { url = "https://files.pythonhosted.org/packages/21/e2/62634b7fff918ed98c3c6b2f0e70d520f7f28846cb412d451b04354c6459/aiohttp-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbec68ce61b64cb73cab4d33df9433427b1713c8bcccb181dce695c1b6f8e87c", size = 506966, upload-time = "2026-06-01T19:37:50.014Z" }, - { url = "https://files.pythonhosted.org/packages/dd/fb/5ce075150828c797a5106f1c2fb26034e709d4289b9d2bf8b07f1e59fac6/aiohttp-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cdf534aa455593e589302990c5097aa5c92c06c4262a20da22934f9186a5fff", size = 507527, upload-time = "2026-06-01T19:37:51.96Z" }, - { url = "https://files.pythonhosted.org/packages/01/d5/405a0ae4e6b081754a3609c1c97c63a950e000a2def16046f1e736933a0e/aiohttp-3.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb6c657104393b5fbff01a5f59b2023db74058a8077d94475d6c25d03882a108", size = 1762420, upload-time = "2026-06-01T19:37:53.839Z" }, - { url = "https://files.pythonhosted.org/packages/ae/1d/e05a7c896b15a6bc6fb8fc5319eb437861c2c49c34559ef928add6590315/aiohttp-3.14.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:46fbbec4e4fab7428d4396a3823f9320e4560aa3113b89eeebce712c27c9ed5a", size = 1733672, upload-time = "2026-06-01T19:37:55.791Z" }, - { url = "https://files.pythonhosted.org/packages/cc/22/a72f7c459e195fa41bf4f7abd1f925b91fe91f8097e51c654229ba144a33/aiohttp-3.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c2c7e05dd5335b298085abf45ddf98673934c3ee1c083d0b9ea13d4186ad500", size = 1805064, upload-time = "2026-06-01T19:37:57.931Z" }, - { url = "https://files.pythonhosted.org/packages/80/50/e85bdaba0be59ca4838005ebfef4048fcdd5f35a02b07057a9a123394440/aiohttp-3.14.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c7139100fbaae76515b73051d8f0aa3a3ff02e415eec8a8eee8e2223d9ba955", size = 1902125, upload-time = "2026-06-01T19:38:00.225Z" }, - { url = "https://files.pythonhosted.org/packages/19/d8/51de5c6b971c27bb1ef620293b8d1ca611ec78736b34b3f6ccf68e4c8785/aiohttp-3.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d6f9286a629ce52728430afe18f8ed2b6c39a1fddb3802d7244b9983910ad2", size = 1783112, upload-time = "2026-06-01T19:38:02.641Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b4402bfde77e43dfb1b6ccff83c7b7ab63ed06b50c4754f0c5423fb374fe/aiohttp-3.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3c3e12cdaeb92d7dcf13db00e9f6b1956b910e47256e696df1cfa946d02159", size = 1586356, upload-time = "2026-06-01T19:38:04.637Z" }, - { url = "https://files.pythonhosted.org/packages/bc/05/750a3265ca4dc54a460bd0cb1121a8f2ce9171fce4a135fb47ea7fd594d2/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4d6a998191f5ebe3b8c28463ff72bc030250008b3193c402464efadd08b5ca02", size = 1723119, upload-time = "2026-06-01T19:38:06.713Z" }, - { url = "https://files.pythonhosted.org/packages/37/01/8c0812c50b3b1b1c37b323bf170d6be8847a8f234060485b7d1e71953f60/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0fc2b75ae8d169d853be2862d960be8550da6c5c65711d5476407eb3fdb006bd", size = 1757216, upload-time = "2026-06-01T19:38:08.736Z" }, - { url = "https://files.pythonhosted.org/packages/47/2a/50fb98028a26887cbe48dcc1df92a90825615bc73b5584301304090cded8/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:16eee56bcc72d04600bc56c1759982c2385ec0b41d3fd3521f836bf64a0957ef", size = 1770500, upload-time = "2026-06-01T19:38:11.111Z" }, - { url = "https://files.pythonhosted.org/packages/bd/32/0ffd598a2fa2b9a423daf242e700cfdabda35d6e602394ad9ae58972c1c7/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5a2e7ca615c3ddc15b82687e05a624e5f5cba3f1d6c20cb81172d70ea498451e", size = 1576224, upload-time = "2026-06-01T19:38:13.391Z" }, - { url = "https://files.pythonhosted.org/packages/0b/f9/b9fc381dd9b66afb33f2634c40e229d106467be0afcabe79648631ab6712/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0b7b8bbbec3ce9467ee0ebe334622fd90624f593edd3136c567811453fc4fae", size = 1794252, upload-time = "2026-06-01T19:38:15.498Z" }, - { url = "https://files.pythonhosted.org/packages/a8/fb/05d9214c975f23225a8cd5c439325e338c7c377b315480ef3871db51f54e/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ba10966d4f03dd96a14365be4b8e37c327c76f11c3ca867116966cdd9f98066", size = 1760193, upload-time = "2026-06-01T19:38:17.624Z" }, - { url = "https://files.pythonhosted.org/packages/d9/4b/02992fc4fb9e1b6673ee3f888a8e587a6447afda1f6f4aca776c148c2876/aiohttp-3.14.0-cp312-cp312-win32.whl", hash = "sha256:101df7779c80c0636014a6b2c6642acd3efb5b355d48347c9d7dfb720aee9430", size = 448650, upload-time = "2026-06-01T19:38:19.545Z" }, - { url = "https://files.pythonhosted.org/packages/39/e9/246532214c3abda518477cbaaf16d420295ad8effa5233844cbb38f299ab/aiohttp-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0a5747586d4467efd1f932710b269131c9717a872dce082cd92a00c1c13123a", size = 476145, upload-time = "2026-06-01T19:38:21.505Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c3/63f8c20090048915711598b0adf475b149216d736157961de06480a45b15/aiohttp-3.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f1c5be60add78fabb4aacd13c5a348ae79d2fcbfc7fa78da8f1eb192273b370", size = 444250, upload-time = "2026-06-01T19:38:24.027Z" }, - { url = "https://files.pythonhosted.org/packages/21/61/d11f7d9a3144bffe825247d6367cd93053666da50b94707c9129c78868d5/aiohttp-3.14.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:25400d710641a8040bf022a8a99f579e581ffa1c5bd42c33255d7d6f3957c127", size = 502399, upload-time = "2026-06-01T19:38:25.955Z" }, - { url = "https://files.pythonhosted.org/packages/4f/9b/a7e317625d36356844f8bb022cabd305b541f968856cc3c2e0b58e53ee6e/aiohttp-3.14.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:c5492b9929826e07cc3fcb9739ae87aab05dff6b5e67a9b73fd1700c6d008981", size = 510068, upload-time = "2026-06-01T19:38:27.828Z" }, - { url = "https://files.pythonhosted.org/packages/11/41/cc2d2cfbfbdc3126ba258f3cd27d1ac8a33492ae3c35a4583ee21f0ba7f1/aiohttp-3.14.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3366751d68d237c621264233a32f3078bbc21b7904ab90a77e03d21390c742c6", size = 481670, upload-time = "2026-06-01T19:38:29.836Z" }, - { url = "https://files.pythonhosted.org/packages/3c/07/381f4023c3b08cb616e520f566d8c58957abad54e56441d41fe67cfb0195/aiohttp-3.14.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:57ea07d28695a7a40304d42251892a8df765e5588c10ee32afeddcd5df33c0a2", size = 487591, upload-time = "2026-06-01T19:38:31.704Z" }, - { url = "https://files.pythonhosted.org/packages/fb/4d/4506fdb7a022bdf70011a3bbb4ca00c5c570026ef6a3c5bd7bc70c39089c/aiohttp-3.14.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:076cb014191ae2e65d949e1ad01f1dcfe33e32789b5172510f3e79c79fc04d50", size = 496503, upload-time = "2026-06-01T19:38:33.6Z" }, - { url = "https://files.pythonhosted.org/packages/ef/7d/c814111e04894a45d9e2defc94443879a6f118d9633d5fedfe6e2e8af5f0/aiohttp-3.14.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2f3fc37054564dee64a855b5b092d87ec35dcddfaabf7dacb1c8a2b1f83dc0a9", size = 745870, upload-time = "2026-06-01T19:38:36.013Z" }, - { url = "https://files.pythonhosted.org/packages/c6/ee/80eee0efddfe187e7cd05027086b7ce1c0e492e82a4eda58f5c5543a44a0/aiohttp-3.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8fcaef74d2ab0f607d7ff85a0d15e21bb5a258c4a58df1908396eb50d7f4ed3c", size = 505588, upload-time = "2026-06-01T19:38:38.282Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f8/0f28f04eef75d52fc9c715dde7ce9c0abb810fd20cfeb0fea7afd2ab1e98/aiohttp-3.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e4c01b0bfc6209590960e68eac083cd22d5d87c21f974dd6208cafa5d3542bc8", size = 504492, upload-time = "2026-06-01T19:38:40.611Z" }, - { url = "https://files.pythonhosted.org/packages/ff/db/44c755232085545065c94378dfce38641b1aee647f4939fcd32f5b32e719/aiohttp-3.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f12eb7896e81caf403a2b18c9406426f1207361e7239c057ab29c076d4257e83", size = 1752111, upload-time = "2026-06-01T19:38:42.682Z" }, - { url = "https://files.pythonhosted.org/packages/5e/6a/42e030a46743841414402a3b00cd3d78419055e86c66fb5822c14b5abfc6/aiohttp-3.14.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c79a044cacf360ec46738d863d2f41c9300d2a06ef4a7402ea0df306a350e61", size = 1729674, upload-time = "2026-06-01T19:38:44.79Z" }, - { url = "https://files.pythonhosted.org/packages/34/26/3199beb415202e3108e7b83ecebe10914d806d33fb9860c3e4aa60a19be3/aiohttp-3.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85e0675f47be4eff0636bf88c02140ea89168ae0df3ff1f3f464e9de9610d277", size = 1798808, upload-time = "2026-06-01T19:38:47.01Z" }, - { url = "https://files.pythonhosted.org/packages/bd/94/b9b6fcf0ee17c21d0d19fb8c22bf83ad18f82e702a9c3bd901a868f5e446/aiohttp-3.14.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b33e751cab03fdc960095b1e326cb5a03f5ee577d6ded59f3d1c100f8668882", size = 1891921, upload-time = "2026-06-01T19:38:49.233Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a3/3800dbd095cb2bb165a7ea5d94d790914677e27f45638c7d80e3f34c8945/aiohttp-3.14.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26d9224c6dd7f5c749aba4f61315a894601448b28d94d12f4dea0903e26d2096", size = 1777241, upload-time = "2026-06-01T19:38:52.04Z" }, - { url = "https://files.pythonhosted.org/packages/21/2a/45be91ad1b860508557448d4cc2e165a2ee68dd865657b73bf66cc5a00fb/aiohttp-3.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6281aecdf2732940f4fe06bd6adec5ae4d59b78b080b8e3a6b81467301010988", size = 1579554, upload-time = "2026-06-01T19:38:54.508Z" }, - { url = "https://files.pythonhosted.org/packages/b4/3d/dc94df99ed1511fdf28314f722643ed334112643cab00223577085e788c4/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23e8314e7aed8576fbe33314d218bd81447a3adbc91dc36f1163bf583cd3084c", size = 1714864, upload-time = "2026-06-01T19:38:56.788Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e4/1f1c8acbb3acd5c8f795473b92c9c3d44eb60a5692c6104256c8a1c83a0c/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3b54fbff46127aeafdd764cecd0d99fa2f24a0e37ea5c18a7c3a4ac450df1db3", size = 1749803, upload-time = "2026-06-01T19:38:59.367Z" }, - { url = "https://files.pythonhosted.org/packages/0b/c8/c45ea6e7ed84cebba939b9c334498a045ba19d79c61b0110df5f21580de3/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b27d89af91a555f58e08e4902dbcbc48862fd40095720ca705990476bd93b7ac", size = 1765023, upload-time = "2026-06-01T19:39:01.651Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a1/a932941784432962fe390e1066823aaef64b4e5ac9fa595df57b5fe472a9/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:25d2326a4967bf705a9f9913a13005e93b6020ad8a9f6bd6bd78850d5171332e", size = 1571671, upload-time = "2026-06-01T19:39:04.044Z" }, - { url = "https://files.pythonhosted.org/packages/b0/01/e1280feac522597a4d46eb67a0cdfa053cfae263033030b761ab146f29fb/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a1d209375c503472b3c0a340cdf3c55fcd82e84b46dda7caeaced59faba373ec", size = 1789904, upload-time = "2026-06-01T19:39:06.294Z" }, - { url = "https://files.pythonhosted.org/packages/fa/10/ab28818262f4d26bdb47ed5f1fc7999b69e2fc6e0370b02d0f49011f45ea/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:666c7c5036df57b693026398b69b41874a1931ac5b3485fd910e57bfac253869", size = 1754516, upload-time = "2026-06-01T19:39:08.788Z" }, - { url = "https://files.pythonhosted.org/packages/af/cc/c122eabd7a1b7e0c9bbdd6be60e4715905b858399145d9df872bb94f1427/aiohttp-3.14.0-cp313-cp313-win32.whl", hash = "sha256:23f094a1ef64823fd35854ddf5c7a80a078162f37f9d2f7c6142b51a6affa456", size = 448656, upload-time = "2026-06-01T19:39:11.171Z" }, - { url = "https://files.pythonhosted.org/packages/41/a5/bab07d79848a00eedd8ed979ccb302aaea3ac6eb9fa16bd0ed87135869b4/aiohttp-3.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e03abdaa17d553f17e1d1d06bb266b3970106c78051d06795723e748d8e49d11", size = 475803, upload-time = "2026-06-01T19:39:13.439Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/f03ade8566c153666a3871afccbedf6d99911da006325e1fc6cf72a2de99/aiohttp-3.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:acdb400538cf4769543548bb5d1eb23d39bed4f96554a6078cb728c7cb2c268b", size = 443889, upload-time = "2026-06-01T19:39:15.945Z" }, - { url = "https://files.pythonhosted.org/packages/28/03/5f36ab196a88ba5e9648ae5643e6531e67a3a8c0e96f9c6510ff41540fec/aiohttp-3.14.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:363ef9e91014e7891679bfb2ac0a7c6ea93435dbbfd10ecf41b9f06fcf506c5f", size = 503330, upload-time = "2026-06-01T19:39:18.195Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ce/8b49ec2f30f68e02f314f4832186cd45e583360a5a386058be36855d23b6/aiohttp-3.14.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:884a4edbdad77be9d0ef36142c8b504351b170df0bf62b51e784fadabf311c42", size = 509822, upload-time = "2026-06-01T19:39:20.396Z" }, - { url = "https://files.pythonhosted.org/packages/1a/fe/6edbf5d39bf29322b6816365b17ed8ede4dace164a3aea1abcd30110eb78/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:70ea956f6cc4a37620966b56c2e205d88ca3e6d85ec063277e414b1035cddad3", size = 483329, upload-time = "2026-06-01T19:39:22.607Z" }, - { url = "https://files.pythonhosted.org/packages/1b/5a/fae531bdbc6456fb6241f46b7b81e4d8a0dd3fc09118a0055dc7141ac1ec/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:ea3b9806c89f61da22fddf1f12dd524fb368e5e28f1261fbdafe5c3cd8ce893b", size = 489502, upload-time = "2026-06-01T19:39:24.881Z" }, - { url = "https://files.pythonhosted.org/packages/36/f4/48a7b0414db7fed77a03d5dde34508c026afd83510ab6bca08c313855776/aiohttp-3.14.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a071be341c2bd9b0188e62d173509f024e0a35b1c342c53c50f8daaeda8c3bd8", size = 497357, upload-time = "2026-06-01T19:39:27.197Z" }, - { url = "https://files.pythonhosted.org/packages/75/75/e85a13a370acc007fca5feb1fd1b88ac2d8426e6dadd625479b7cadd55a3/aiohttp-3.14.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:198cfe61bf253b19da1fb3e0fa122249dc4f14c12709493fed8054aa0411cc76", size = 750898, upload-time = "2026-06-01T19:39:29.563Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e4/3d637f800c724eff0e2bed64df72557444482366fd0a35b0cec0e6968f6c/aiohttp-3.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc203d6ce6b9106d54e2a93f41dfdfebfbca2d99962ba503bfd3e5921a6549e", size = 506986, upload-time = "2026-06-01T19:39:31.872Z" }, - { url = "https://files.pythonhosted.org/packages/1d/df/35161f3598bf7501d2b2a805b41ab4f45a2e34150c421bcb4ef8c0d281a7/aiohttp-3.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e19d17ab02bf16832a2c8c0d55a486792c5b1645665652ee9531aebcc30cb72", size = 508033, upload-time = "2026-06-01T19:39:34.137Z" }, - { url = "https://files.pythonhosted.org/packages/e5/39/b36e5d3d31e850fb4691dd3e941684ac490a2559249f6fa634b6b0fdf020/aiohttp-3.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d925fba0c14d5b498a8028b0107beebdfd16c5d48d702ff54f879cb017aaaca3", size = 1746213, upload-time = "2026-06-01T19:39:36.654Z" }, - { url = "https://files.pythonhosted.org/packages/b1/28/24e1409e605a9aa5d84abe0e2acb365354b70ae56d40948101cabe3341ab/aiohttp-3.14.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d33e61021222ce7f9792bcac870d6f58d8adfceda33ab857b01264f4560f2c5f", size = 1705862, upload-time = "2026-06-01T19:39:38.968Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d0/e5eb3ff1daeaf644c7e36a957517672494122628e067c38b263fa04eda77/aiohttp-3.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:44eca38755d0105bb32f47d085f5dd449846a449e1245fc105889e3279dcf8e3", size = 1798909, upload-time = "2026-06-01T19:39:41.334Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ba/8943f906f0570342886ababb9a722a44e360f786a028c5e0b0e29e3f735b/aiohttp-3.14.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f13087e06f68fea4941c21a0c541c00553aa16e4f8fd7bbe2b198df761e964d6", size = 1868892, upload-time = "2026-06-01T19:39:43.807Z" }, - { url = "https://files.pythonhosted.org/packages/3a/05/27df32c844b2156e1675a8d8ec22d963e3c8ba469ed7ceb1863320c7b521/aiohttp-3.14.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff82be7f1ef73634cb77890a770743239bc3d487b848669be1c599889336dc0a", size = 1751659, upload-time = "2026-06-01T19:39:46.398Z" }, - { url = "https://files.pythonhosted.org/packages/7f/62/da182e5910ab912b2e88aa919b61a16046a37a95714a5795b02eb57b2d18/aiohttp-3.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a150c0875ac8fd87f1c398650841308a30d65facf7416b12dbdb9cfdcbe5a48c", size = 1578775, upload-time = "2026-06-01T19:39:48.902Z" }, - { url = "https://files.pythonhosted.org/packages/66/e3/53c67097e8a5ce98625e91e3fa7f43c9c6940de680345d03b3509a72a078/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:edc01ea4e1ec5a1649a28866262bf24195889ff7b27bdd947029a6086741de9b", size = 1710090, upload-time = "2026-06-01T19:39:51.392Z" }, - { url = "https://files.pythonhosted.org/packages/dd/55/0e2732ca598c7a4dfe8a775662376d0ca2977cb1030e48386d4da5d9a456/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:540632bf882ff8fc88f2e1697be0761578e89e0d79fb4a8a6d65dc5da7e729d4", size = 1715016, upload-time = "2026-06-01T19:39:53.807Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/f0b73730798c9ca525afc30b39f1f81bbe24e245d9654c54d3b39d63212d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:860a86bc2c80237f5dff52edcf427e10a8d8352271fd84845429a3e60199e02c", size = 1763810, upload-time = "2026-06-01T19:39:56.31Z" }, - { url = "https://files.pythonhosted.org/packages/71/cc/11acb6c4518f448323405a7312b6f255d0f974a34373ad1db7633c4aadc8/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5cbd50e6a50d6b99283a826b18cbdebf65b0797689a7535cb0e9dd37be0f63c3", size = 1573064, upload-time = "2026-06-01T19:39:58.718Z" }, - { url = "https://files.pythonhosted.org/packages/de/2d/28c31dde0a7dc98c0ee7d0da2ddcec3f7688c4fc131e5989e278d0c03c0a/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:20144819e99db593e22bbd2f3f2691a5e149f879142d6b8670254708853ff4fb", size = 1775765, upload-time = "2026-06-01T19:40:01.195Z" }, - { url = "https://files.pythonhosted.org/packages/b8/69/155c4ef3aec96417d47024800472b33b16c5d8a665371dcd044c2afdf25d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:26b6d79aa54cb4ed50cc7d41ed14e99e0f1fc8e7c2d42f2e05b37aea897b2b52", size = 1733716, upload-time = "2026-06-01T19:40:03.631Z" }, - { url = "https://files.pythonhosted.org/packages/5f/44/6126116fd8a316b712bb615660b855c78466bb67ba1bb1742427eafcf7ac/aiohttp-3.14.0-cp314-cp314-win32.whl", hash = "sha256:106ed074a856f3e21d186b8579e2c8afb6da598e267cdaab01059e13db2fc44d", size = 453684, upload-time = "2026-06-01T19:40:06.277Z" }, - { url = "https://files.pythonhosted.org/packages/a2/d7/eff4c58a88c5cac5e38b55f44fb8a6d3929c3cbd77356e383e094d3220bd/aiohttp-3.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f770846edae8f00ecc57af825bce811f787f87a7dcf0e90d191790efe5b31f7", size = 481758, upload-time = "2026-06-01T19:40:08.653Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ed/17b5bd9fbcb46e688f02e572f517754a9a75831e7b54702f027761dc4fa5/aiohttp-3.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:acf1581c4f21ed4b80a2dded504d87b055a071a84d5737ea966435f768275ac6", size = 450557, upload-time = "2026-06-01T19:40:11.03Z" }, - { url = "https://files.pythonhosted.org/packages/12/34/6180103ce9aabc8ebff3f7bb55a1228ffe60f61042823031d9692cb7b101/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6aa1a40f9cbb3da9f80714c5966b8946c21e6a2530d809b9498b33161e3c8733", size = 787878, upload-time = "2026-06-01T19:40:13.401Z" }, - { url = "https://files.pythonhosted.org/packages/92/e9/08954a40e8b7baa3d8beadd2b074b186e9b1e9c8ddabc288678a6265de50/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b62af5a8cc96a194eaa01a9ed7b34a3ffa58d3d8daaa1a0d7a749353ad12d228", size = 524400, upload-time = "2026-06-01T19:40:15.972Z" }, - { url = "https://files.pythonhosted.org/packages/08/6a/b5965a634ac4d5ba99a463314cf4ab214ca073fcdc38a15e0294273701fc/aiohttp-3.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6eb63b1417efaf7d1002a6ad034a40d44376afcc16508a57f8e74b49ad26a095", size = 527904, upload-time = "2026-06-01T19:40:18.28Z" }, - { url = "https://files.pythonhosted.org/packages/06/b4/932bcdd850c354d9bcca30f360e475d7852e30413fbbd44b182782ed5432/aiohttp-3.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c20b9ad156a79eb97be5cf9e069eec01d2f0dc8472ffbd75299a8b2d4c2cbbde", size = 1912162, upload-time = "2026-06-01T19:40:20.825Z" }, - { url = "https://files.pythonhosted.org/packages/c6/85/ce79bab0310d2e3fd2d7bc7e44412abeff7c8338f8a21dd0f2f1714989e5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:40ae7b0642c25632c7eabc4a04754012691864d2a1b93becf7cddb76027b838a", size = 1778813, upload-time = "2026-06-01T19:40:23.726Z" }, - { url = "https://files.pythonhosted.org/packages/05/54/ba62ac2d1bc87e010aad23751e383b8794e45d931df67677313a2da78823/aiohttp-3.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95f5217e76a046b9f228a101717ef8d42b1eb3d9d196d15202db5bf41df88936", size = 1899969, upload-time = "2026-06-01T19:40:26.406Z" }, - { url = "https://files.pythonhosted.org/packages/dc/82/7cc7907725d83a19f31551334061e1ab8e108b1d7ac52632a2a844a4acb5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a4a9f17e85b80878c176695c1998c790e83731d8271881e5d356488652a1f9e", size = 1991771, upload-time = "2026-06-01T19:40:29.061Z" }, - { url = "https://files.pythonhosted.org/packages/d0/1c/a57de71a4508c93a830b77c28af3d08cd97f606dedfc6b94275347744508/aiohttp-3.14.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:145262119b07d7f95abc1839add35ba2bfc84551d4b4660ca11542c0b215455b", size = 1868606, upload-time = "2026-06-01T19:40:31.843Z" }, - { url = "https://files.pythonhosted.org/packages/9c/ae/3839726cd49150a53ed340cc24ce5ba09d4c2117020ef9d45542bec5eb2f/aiohttp-3.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:49a33ded29b0b2fa7a367a02cf0fb89af602bb87542a16177ec8ce1c9c51d12a", size = 1665437, upload-time = "2026-06-01T19:40:35.01Z" }, - { url = "https://files.pythonhosted.org/packages/35/1e/c237923232c7da7f0392ea25d89fc5e60c0e93f685f4ebca8e7bcdd5271c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cc736a9c9fc2bc4dd71fd404815741b6573df27c3f985948ec4076989ac57de", size = 1834090, upload-time = "2026-06-01T19:40:37.733Z" }, - { url = "https://files.pythonhosted.org/packages/98/02/a5a7a2524f92d3911761b405a7c067c751891942144adc13e2ad79611e39/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4141a3e5342ee3053a9cab54d25b64ed28289c1041e4c54b3d99839314d90ce", size = 1816907, upload-time = "2026-06-01T19:40:40.46Z" }, - { url = "https://files.pythonhosted.org/packages/fa/76/a8b9f0d09234d516af9f2d7dd715557f33b5da3b0b56ead41d1170e86e3c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e30871b2d58996cb81aac52d2b1d15ac05257131ef0f90f18c2115a380fbfe7c", size = 1840382, upload-time = "2026-06-01T19:40:43.48Z" }, - { url = "https://files.pythonhosted.org/packages/c9/8e/140e715a0a4bbc211979ea30ec8396ad2ed5bf90ab87d8058fc4668b1923/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:667b881d083ccae3900ea5a241e17e5007ca78844c53ed389bb63d48f729d9c7", size = 1659497, upload-time = "2026-06-01T19:40:46.265Z" }, - { url = "https://files.pythonhosted.org/packages/10/c7/7ba5de8af9650b9767b063c675427b8685f43fa7ce563673a7bc3af60f08/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:b584dfe615d151e9b8f0a8ecb3aee6147f2927ec5b95ba25fe621f5377510928", size = 1870829, upload-time = "2026-06-01T19:40:49.583Z" }, - { url = "https://files.pythonhosted.org/packages/cc/bc/2aaab2f85cadb26ea59c091fa2b8e370d625154b5c14b478f1b489d07551/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6199707cc40e0e9cd39c36fbc97bec416c704e1d0ddce03412bb3b3e6a90ccd0", size = 1832281, upload-time = "2026-06-01T19:40:52.303Z" }, - { url = "https://files.pythonhosted.org/packages/39/98/31b9ad9fbc01f0075ee7221002df5fd2d10b647f451ca5f30edc802d9dd6/aiohttp-3.14.0-cp314-cp314t-win32.whl", hash = "sha256:a8d93334d4961c9d566b1f046c81dee475b7c21eb730728d38237bfa70d1c8e6", size = 490597, upload-time = "2026-06-01T19:40:54.937Z" }, - { url = "https://files.pythonhosted.org/packages/59/1f/299b21441c8de42ff70fddc7cfe65e92f810abcf740739a09b56f7835364/aiohttp-3.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d2ffe9b614f50f069068b3b52e73414e4107fc10b7efc939a76acff9251fdd2", size = 525789, upload-time = "2026-06-01T19:40:57.306Z" }, - { url = "https://files.pythonhosted.org/packages/70/11/7f83fcba9ee05d4c54d61b3f8104da0d43a59adac44dd28effc0c9a10422/aiohttp-3.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7a3fc4358e65826c515350f199c210de747cf669998211b1ee6c2e46de364b24", size = 467399, upload-time = "2026-06-01T19:40:59.993Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, ] [[package]] @@ -633,61 +636,58 @@ toml = [ [[package]] name = "cryptography" -version = "48.0.0" +version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, - { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, - { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, - { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, - { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, - { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, - { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, - { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, - { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, - { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, - { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, - { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, - { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, - { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, - { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, - { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, - { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, - { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, - { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, - { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, - { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, - { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, - { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, - { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, - { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, - { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, - { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, - { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, - { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, - { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, - { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, - { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, - { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, ] [[package]] @@ -718,6 +718,7 @@ version = "7.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "license-expression" }, + { name = "lxml" }, { name = "packageurl-python" }, { name = "py-serializable" }, { name = "sortedcontainers" }, @@ -730,7 +731,6 @@ wheels = [ [package.optional-dependencies] validation = [ { name = "jsonschema", extra = ["format"] }, - { name = "lxml" }, ] [[package]] @@ -1674,63 +1674,118 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, ] +[[package]] +name = "licenseheaders" +version = "0.8.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/a1/70ad273ee6f78cb8d6acdd0bac035a8ab4f392dd83125ca3967632cd3937/licenseheaders-0.8.8.tar.gz", hash = "sha256:feb49c1a869f415431503ed56f4f3be48a4161495d3082f44af76c42c6a7e9ef", size = 21844, upload-time = "2021-04-08T18:48:46.932Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/03/99102b3772bdc5d25fc7fe5f5fb862c54bb6e863991f50d02667999942c1/licenseheaders-0.8.8-py3-none-any.whl", hash = "sha256:3b159228b37bbba98bd01448c41a5eff773ab26ac5b14ac98c53d06dbc807696", size = 21272, upload-time = "2021-04-08T18:48:43.871Z" }, +] + [[package]] name = "lxml" -version = "5.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/3d/14e82fc7c8fb1b7761f7e748fd47e2ec8276d137b6acfe5a4bb73853e08f/lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd", size = 3679479, upload-time = "2025-04-23T01:50:29.322Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/2d/67693cc8a605a12e5975380d7ff83020dcc759351b5a066e1cced04f797b/lxml-5.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9", size = 8083240, upload-time = "2025-04-23T01:45:18.566Z" }, - { url = "https://files.pythonhosted.org/packages/73/53/b5a05ab300a808b72e848efd152fe9c022c0181b0a70b8bca1199f1bed26/lxml-5.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7", size = 4387685, upload-time = "2025-04-23T01:45:21.387Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cb/1a3879c5f512bdcd32995c301886fe082b2edd83c87d41b6d42d89b4ea4d/lxml-5.4.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa", size = 4991164, upload-time = "2025-04-23T01:45:23.849Z" }, - { url = "https://files.pythonhosted.org/packages/f9/94/bbc66e42559f9d04857071e3b3d0c9abd88579367fd2588a4042f641f57e/lxml-5.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e16c08254b9b6466526bc1828d9370ee6c0d60a4b64836bc3ac2917d1e16df", size = 4746206, upload-time = "2025-04-23T01:45:26.361Z" }, - { url = "https://files.pythonhosted.org/packages/66/95/34b0679bee435da2d7cae895731700e519a8dfcab499c21662ebe671603e/lxml-5.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7605c1c32c3d6e8c990dd28a0970a3cbbf1429d5b92279e37fda05fb0c92190e", size = 5342144, upload-time = "2025-04-23T01:45:28.939Z" }, - { url = "https://files.pythonhosted.org/packages/e0/5d/abfcc6ab2fa0be72b2ba938abdae1f7cad4c632f8d552683ea295d55adfb/lxml-5.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecf4c4b83f1ab3d5a7ace10bafcb6f11df6156857a3c418244cef41ca9fa3e44", size = 4825124, upload-time = "2025-04-23T01:45:31.361Z" }, - { url = "https://files.pythonhosted.org/packages/5a/78/6bd33186c8863b36e084f294fc0a5e5eefe77af95f0663ef33809cc1c8aa/lxml-5.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cef4feae82709eed352cd7e97ae062ef6ae9c7b5dbe3663f104cd2c0e8d94ba", size = 4876520, upload-time = "2025-04-23T01:45:34.191Z" }, - { url = "https://files.pythonhosted.org/packages/3b/74/4d7ad4839bd0fc64e3d12da74fc9a193febb0fae0ba6ebd5149d4c23176a/lxml-5.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:df53330a3bff250f10472ce96a9af28628ff1f4efc51ccba351a8820bca2a8ba", size = 4765016, upload-time = "2025-04-23T01:45:36.7Z" }, - { url = "https://files.pythonhosted.org/packages/24/0d/0a98ed1f2471911dadfc541003ac6dd6879fc87b15e1143743ca20f3e973/lxml-5.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:aefe1a7cb852fa61150fcb21a8c8fcea7b58c4cb11fbe59c97a0a4b31cae3c8c", size = 5362884, upload-time = "2025-04-23T01:45:39.291Z" }, - { url = "https://files.pythonhosted.org/packages/48/de/d4f7e4c39740a6610f0f6959052b547478107967362e8424e1163ec37ae8/lxml-5.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ef5a7178fcc73b7d8c07229e89f8eb45b2908a9238eb90dcfc46571ccf0383b8", size = 4902690, upload-time = "2025-04-23T01:45:42.386Z" }, - { url = "https://files.pythonhosted.org/packages/07/8c/61763abd242af84f355ca4ef1ee096d3c1b7514819564cce70fd18c22e9a/lxml-5.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d2ed1b3cb9ff1c10e6e8b00941bb2e5bb568b307bfc6b17dffbbe8be5eecba86", size = 4944418, upload-time = "2025-04-23T01:45:46.051Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c5/6d7e3b63e7e282619193961a570c0a4c8a57fe820f07ca3fe2f6bd86608a/lxml-5.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:72ac9762a9f8ce74c9eed4a4e74306f2f18613a6b71fa065495a67ac227b3056", size = 4827092, upload-time = "2025-04-23T01:45:48.943Z" }, - { url = "https://files.pythonhosted.org/packages/71/4a/e60a306df54680b103348545706a98a7514a42c8b4fbfdcaa608567bb065/lxml-5.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f5cb182f6396706dc6cc1896dd02b1c889d644c081b0cdec38747573db88a7d7", size = 5418231, upload-time = "2025-04-23T01:45:51.481Z" }, - { url = "https://files.pythonhosted.org/packages/27/f2/9754aacd6016c930875854f08ac4b192a47fe19565f776a64004aa167521/lxml-5.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3a3178b4873df8ef9457a4875703488eb1622632a9cee6d76464b60e90adbfcd", size = 5261798, upload-time = "2025-04-23T01:45:54.146Z" }, - { url = "https://files.pythonhosted.org/packages/38/a2/0c49ec6941428b1bd4f280650d7b11a0f91ace9db7de32eb7aa23bcb39ff/lxml-5.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e094ec83694b59d263802ed03a8384594fcce477ce484b0cbcd0008a211ca751", size = 4988195, upload-time = "2025-04-23T01:45:56.685Z" }, - { url = "https://files.pythonhosted.org/packages/7a/75/87a3963a08eafc46a86c1131c6e28a4de103ba30b5ae903114177352a3d7/lxml-5.4.0-cp311-cp311-win32.whl", hash = "sha256:4329422de653cdb2b72afa39b0aa04252fca9071550044904b2e7036d9d97fe4", size = 3474243, upload-time = "2025-04-23T01:45:58.863Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f9/1f0964c4f6c2be861c50db380c554fb8befbea98c6404744ce243a3c87ef/lxml-5.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539", size = 3815197, upload-time = "2025-04-23T01:46:01.096Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4c/d101ace719ca6a4ec043eb516fcfcb1b396a9fccc4fcd9ef593df34ba0d5/lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4", size = 8127392, upload-time = "2025-04-23T01:46:04.09Z" }, - { url = "https://files.pythonhosted.org/packages/11/84/beddae0cec4dd9ddf46abf156f0af451c13019a0fa25d7445b655ba5ccb7/lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d", size = 4415103, upload-time = "2025-04-23T01:46:07.227Z" }, - { url = "https://files.pythonhosted.org/packages/d0/25/d0d93a4e763f0462cccd2b8a665bf1e4343dd788c76dcfefa289d46a38a9/lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779", size = 5024224, upload-time = "2025-04-23T01:46:10.237Z" }, - { url = "https://files.pythonhosted.org/packages/31/ce/1df18fb8f7946e7f3388af378b1f34fcf253b94b9feedb2cec5969da8012/lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e", size = 4769913, upload-time = "2025-04-23T01:46:12.757Z" }, - { url = "https://files.pythonhosted.org/packages/4e/62/f4a6c60ae7c40d43657f552f3045df05118636be1165b906d3423790447f/lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9", size = 5290441, upload-time = "2025-04-23T01:46:16.037Z" }, - { url = "https://files.pythonhosted.org/packages/9e/aa/04f00009e1e3a77838c7fc948f161b5d2d5de1136b2b81c712a263829ea4/lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5", size = 4820165, upload-time = "2025-04-23T01:46:19.137Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/e0b2f61fa2404bf0f1fdf1898377e5bd1b74cc9b2cf2c6ba8509b8f27990/lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5", size = 4932580, upload-time = "2025-04-23T01:46:21.963Z" }, - { url = "https://files.pythonhosted.org/packages/24/a2/8263f351b4ffe0ed3e32ea7b7830f845c795349034f912f490180d88a877/lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4", size = 4759493, upload-time = "2025-04-23T01:46:24.316Z" }, - { url = "https://files.pythonhosted.org/packages/05/00/41db052f279995c0e35c79d0f0fc9f8122d5b5e9630139c592a0b58c71b4/lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e", size = 5324679, upload-time = "2025-04-23T01:46:27.097Z" }, - { url = "https://files.pythonhosted.org/packages/1d/be/ee99e6314cdef4587617d3b3b745f9356d9b7dd12a9663c5f3b5734b64ba/lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7", size = 4890691, upload-time = "2025-04-23T01:46:30.009Z" }, - { url = "https://files.pythonhosted.org/packages/ad/36/239820114bf1d71f38f12208b9c58dec033cbcf80101cde006b9bde5cffd/lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079", size = 4955075, upload-time = "2025-04-23T01:46:32.33Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e1/1b795cc0b174efc9e13dbd078a9ff79a58728a033142bc6d70a1ee8fc34d/lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20", size = 4838680, upload-time = "2025-04-23T01:46:34.852Z" }, - { url = "https://files.pythonhosted.org/packages/72/48/3c198455ca108cec5ae3662ae8acd7fd99476812fd712bb17f1b39a0b589/lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8", size = 5391253, upload-time = "2025-04-23T01:46:37.608Z" }, - { url = "https://files.pythonhosted.org/packages/d6/10/5bf51858971c51ec96cfc13e800a9951f3fd501686f4c18d7d84fe2d6352/lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f", size = 5261651, upload-time = "2025-04-23T01:46:40.183Z" }, - { url = "https://files.pythonhosted.org/packages/2b/11/06710dd809205377da380546f91d2ac94bad9ff735a72b64ec029f706c85/lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc", size = 5024315, upload-time = "2025-04-23T01:46:43.333Z" }, - { url = "https://files.pythonhosted.org/packages/f5/b0/15b6217834b5e3a59ebf7f53125e08e318030e8cc0d7310355e6edac98ef/lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f", size = 3486149, upload-time = "2025-04-23T01:46:45.684Z" }, - { url = "https://files.pythonhosted.org/packages/91/1e/05ddcb57ad2f3069101611bd5f5084157d90861a2ef460bf42f45cced944/lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2", size = 3817095, upload-time = "2025-04-23T01:46:48.521Z" }, - { url = "https://files.pythonhosted.org/packages/87/cb/2ba1e9dd953415f58548506fa5549a7f373ae55e80c61c9041b7fd09a38a/lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0", size = 8110086, upload-time = "2025-04-23T01:46:52.218Z" }, - { url = "https://files.pythonhosted.org/packages/b5/3e/6602a4dca3ae344e8609914d6ab22e52ce42e3e1638c10967568c5c1450d/lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de", size = 4404613, upload-time = "2025-04-23T01:46:55.281Z" }, - { url = "https://files.pythonhosted.org/packages/4c/72/bf00988477d3bb452bef9436e45aeea82bb40cdfb4684b83c967c53909c7/lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76", size = 5012008, upload-time = "2025-04-23T01:46:57.817Z" }, - { url = "https://files.pythonhosted.org/packages/92/1f/93e42d93e9e7a44b2d3354c462cd784dbaaf350f7976b5d7c3f85d68d1b1/lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d", size = 4760915, upload-time = "2025-04-23T01:47:00.745Z" }, - { url = "https://files.pythonhosted.org/packages/45/0b/363009390d0b461cf9976a499e83b68f792e4c32ecef092f3f9ef9c4ba54/lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422", size = 5283890, upload-time = "2025-04-23T01:47:04.702Z" }, - { url = "https://files.pythonhosted.org/packages/19/dc/6056c332f9378ab476c88e301e6549a0454dbee8f0ae16847414f0eccb74/lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551", size = 4812644, upload-time = "2025-04-23T01:47:07.833Z" }, - { url = "https://files.pythonhosted.org/packages/ee/8a/f8c66bbb23ecb9048a46a5ef9b495fd23f7543df642dabeebcb2eeb66592/lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c", size = 4921817, upload-time = "2025-04-23T01:47:10.317Z" }, - { url = "https://files.pythonhosted.org/packages/04/57/2e537083c3f381f83d05d9b176f0d838a9e8961f7ed8ddce3f0217179ce3/lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff", size = 4753916, upload-time = "2025-04-23T01:47:12.823Z" }, - { url = "https://files.pythonhosted.org/packages/d8/80/ea8c4072109a350848f1157ce83ccd9439601274035cd045ac31f47f3417/lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60", size = 5289274, upload-time = "2025-04-23T01:47:15.916Z" }, - { url = "https://files.pythonhosted.org/packages/b3/47/c4be287c48cdc304483457878a3f22999098b9a95f455e3c4bda7ec7fc72/lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8", size = 4874757, upload-time = "2025-04-23T01:47:19.793Z" }, - { url = "https://files.pythonhosted.org/packages/2f/04/6ef935dc74e729932e39478e44d8cfe6a83550552eaa072b7c05f6f22488/lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982", size = 4947028, upload-time = "2025-04-23T01:47:22.401Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f9/c33fc8daa373ef8a7daddb53175289024512b6619bc9de36d77dca3df44b/lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61", size = 4834487, upload-time = "2025-04-23T01:47:25.513Z" }, - { url = "https://files.pythonhosted.org/packages/8d/30/fc92bb595bcb878311e01b418b57d13900f84c2b94f6eca9e5073ea756e6/lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54", size = 5381688, upload-time = "2025-04-23T01:47:28.454Z" }, - { url = "https://files.pythonhosted.org/packages/43/d1/3ba7bd978ce28bba8e3da2c2e9d5ae3f8f521ad3f0ca6ea4788d086ba00d/lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b", size = 5242043, upload-time = "2025-04-23T01:47:31.208Z" }, - { url = "https://files.pythonhosted.org/packages/ee/cd/95fa2201041a610c4d08ddaf31d43b98ecc4b1d74b1e7245b1abdab443cb/lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a", size = 5021569, upload-time = "2025-04-23T01:47:33.805Z" }, - { url = "https://files.pythonhosted.org/packages/2d/a6/31da006fead660b9512d08d23d31e93ad3477dd47cc42e3285f143443176/lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82", size = 3485270, upload-time = "2025-04-23T01:47:36.133Z" }, - { url = "https://files.pythonhosted.org/packages/fc/14/c115516c62a7d2499781d2d3d7215218c0731b2c940753bf9f9b7b73924d/lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f", size = 3814606, upload-time = "2025-04-23T01:47:39.028Z" }, +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461, upload-time = "2026-05-18T19:17:25.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375, upload-time = "2026-05-18T19:17:34.555Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654, upload-time = "2026-05-18T19:17:42.917Z" }, + { url = "https://files.pythonhosted.org/packages/4c/77/1bc7eeb0de4577d783fb625aa092cc9357883bba35845a3666bf1259f3dc/lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a", size = 5067921, upload-time = "2026-05-18T19:17:49.175Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d", size = 5002456, upload-time = "2026-05-18T19:17:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8", size = 5202776, upload-time = "2026-05-18T19:18:08.924Z" }, + { url = "https://files.pythonhosted.org/packages/7a/45/689824ffb237fd10125ad273f32b28ff04dc6203c2822c85ff65a93df65e/lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009", size = 5329945, upload-time = "2026-05-18T19:18:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c0/ef73af53767e958fd87d437c170f272e2f6e6c0f854939f133a895f1e711/lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6", size = 4659237, upload-time = "2026-05-18T19:18:18.657Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/e1158e40397585e91cb0472374a1f63d0926a1ddeaa92f13d1a1ffe306d5/lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8", size = 5265904, upload-time = "2026-05-18T19:18:24.883Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/8687e5d1400ed1c0bc41dace232ebb7553952b618ea1f2e5fb6e2cfbbe23/lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83", size = 5045225, upload-time = "2026-05-18T19:17:20.073Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/d877bd1ae2e5ffdfd4836565aba350db31feb2f2656d6ce70316ed66a05e/lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6", size = 4712721, upload-time = "2026-05-18T19:17:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/44/4d/1f44fd1d770b10dacbf6b5c6e520f4d6e0708744930f719dc04e67cab981/lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c", size = 5252549, upload-time = "2026-05-18T19:17:51.236Z" }, + { url = "https://files.pythonhosted.org/packages/64/5d/1d66b84f850089254c230ef6ea6b267a5a54e2e179a5d960036a05d501d7/lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08", size = 5226877, upload-time = "2026-05-18T19:18:00.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/00/84c4b5302d42a2d0184f38d538c8a197f33b52a50bd4f7bcfe990bce3036/lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621", size = 3594072, upload-time = "2026-05-18T19:17:12.714Z" }, + { url = "https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28", size = 4025469, upload-time = "2026-05-18T19:17:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/570e6390e4110331e6208b2ba83d1482cc9146808ee118b22824a34c1070/lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b", size = 3667640, upload-time = "2026-05-19T19:22:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" }, + { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" }, + { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" }, + { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" }, + { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" }, + { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" }, + { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" }, + { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" }, + { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, + { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, + { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, + { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, + { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, + { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, + { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, + { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, + { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, + { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, + { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, + { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/b5/32/86a3f0f724a3a402d4627937a7fc27b160e45e7012b4adf47f6e1e844511/lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e", size = 3930127, upload-time = "2026-05-18T19:19:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/d832e82af08723761556d004b1d04d281c09f9a8cecd7d3148548c9941a3/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004", size = 4210769, upload-time = "2026-05-18T19:20:41.427Z" }, + { url = "https://files.pythonhosted.org/packages/6d/39/0dc5949f759ed7d951e0bb8c2f2d9d7aca1908d22352fa84a8afd2ea54af/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e", size = 4318163, upload-time = "2026-05-18T19:20:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fb/8ab3845fe046ba4cbf74536bcf6801a774b7caf4350de1c5d37f1f0a9e90/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2", size = 4250945, upload-time = "2026-05-18T19:20:47.385Z" }, + { url = "https://files.pythonhosted.org/packages/68/1b/7553ab136894374ffae8851ec06f98f511cd8e66246e41b6be059d0a7289/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf", size = 4401664, upload-time = "2026-05-18T19:20:50.489Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989, upload-time = "2026-05-18T19:18:38.158Z" }, ] [[package]] @@ -1855,55 +1910,65 @@ wheels = [ [[package]] name = "msgpack" -version = "1.1.2" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, - { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, - { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, - { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, - { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, - { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, - { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, - { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, - { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, - { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, - { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, - { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, - { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, - { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, - { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, - { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, - { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, - { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, - { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, - { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, - { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, - { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, - { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, - { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, - { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, - { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, - { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" }, + { url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" }, + { url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" }, + { url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" }, + { url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" }, + { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, + { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, + { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, ] [[package]] @@ -2133,6 +2198,7 @@ dev = [ dev = [ { name = "bandit" }, { name = "cyclonedx-bom" }, + { name = "licenseheaders" }, { name = "mypy" }, { name = "pip-audit" }, { name = "pip-licenses" }, @@ -2141,6 +2207,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-playwright" }, + { name = "reuse" }, { name = "ruff" }, ] @@ -2187,6 +2254,7 @@ dev = [ { name = "bandit", specifier = ">=1.7.0" }, { name = "bandit", extras = ["toml"], specifier = ">=1.7.9" }, { name = "cyclonedx-bom", specifier = "==4.6.1" }, + { name = "licenseheaders", specifier = ">=0.8.8" }, { name = "mypy", specifier = ">=1.9.0" }, { name = "pip-audit", specifier = ">=2.7.0" }, { name = "pip-licenses", specifier = ">=5.0.0" }, @@ -2195,6 +2263,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-playwright", specifier = ">=0.4.0" }, + { name = "reuse", specifier = ">=5.0.0" }, { name = "ruff", specifier = ">=0.3.5" }, ] @@ -3429,16 +3498,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.14.1" +version = "2.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] [[package]] @@ -3568,6 +3637,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-debian" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/36/f90e7d006dd9311a6185f1c34b403dd6d76ff583e7962c56e9374c462a48/python_debian-1.1.1.tar.gz", hash = "sha256:fe4fc3dc798dbf1f0ef5865e2b1b4f7cc0352b6a511b25ab7594906c64a73629", size = 127956, upload-time = "2026-06-07T11:06:58.282Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/47/e184f0004e63dbc49c4bad5e811218f66bece1b519f80b06a619b42e6ec3/python_debian-1.1.1-py3-none-any.whl", hash = "sha256:f98ae013e8e5310e49041cc3860a7105df73af73d4ff1d8afb474770d328a6ad", size = 138101, upload-time = "2026-06-07T11:06:56.632Z" }, +] + [[package]] name = "python-discovery" version = "1.4.0" @@ -3590,13 +3668,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-magic" +version = "0.4.27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/db/0b3e28ac047452d079d375ec6798bf76a036a08182dbb39ed38116a49130/python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b", size = 14677, upload-time = "2022-06-07T20:16:59.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/73/9f872cb81fc5c3bb48f7227872c28975f998f3e7c2b1c16e95e6432bbb90/python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3", size = 13840, upload-time = "2022-06-07T20:16:57.763Z" }, +] + [[package]] name = "python-multipart" -version = "0.0.30" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4b/82/c8cd43a6e0719bf5a3b034f6726dd701f75829c08944c83d4b95d02ed0e8/python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118", size = 46316, upload-time = "2026-05-31T19:24:55.198Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] @@ -3699,6 +3786,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] +[[package]] +name = "regex" +version = "2026.5.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/dc/c1f2df4027e82fc54b5a473e4b250f5139faca49a0fbe29a48668d228f34/regex-2026.5.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ccf5249114cc3e772ecdd88a98a86eca0fd74c61ce32a94743758c083fc05d48", size = 489445, upload-time = "2026-05-09T23:12:06.111Z" }, + { url = "https://files.pythonhosted.org/packages/03/d2/59f01110660081cce9c0bc30ebd0b5ee250dacf658e3248ed92f01e0e8ee/regex-2026.5.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46f1326ca6e65b0879d23ca302c0f2415aad42ff0309b9c818e7949fe19a41d8", size = 291271, upload-time = "2026-05-09T23:12:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/58/b6/14b2c84ff90ddb370c81d27503f4a0fcf071496416f4855f6cc8c5d81c35/regex-2026.5.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef31cbfe458e21c6122ba8150ff060e0c7789ed0d26eb423f25472584920b555", size = 289212, upload-time = "2026-05-09T23:12:09.266Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/4db86529117320de0c84afd90e70bb47434625875e34fcef9d8c127c5b16/regex-2026.5.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:992604d02e6d9c6d786c24a706a71ecffe1020fc1ef264044474cd81fa2c3919", size = 792310, upload-time = "2026-05-09T23:12:11.416Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/fe4800cd322f862ecffd2d553409b20d80650e5ed71b9d178f853d020b82/regex-2026.5.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9411dd64ca95477225734a93dfc8583b51916b8d5942f99d6cac21e09965451", size = 861721, upload-time = "2026-05-09T23:12:13.681Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d0/b3618a895dd8feb897c61bb2954edd265e1767d82a01d53065d5871127a3/regex-2026.5.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4a3ff360dfb836fecdb93a4598f9d6e2ac81e3e397125145c6221bf58cf4c", size = 906460, upload-time = "2026-05-09T23:12:15.443Z" }, + { url = "https://files.pythonhosted.org/packages/33/6f/1481597e859ef19508b345eec4afd1416ed6e6b459c75a64026ef193aecf/regex-2026.5.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a661a7d270a61f7cf460caee8b9fa2d5ef9e5c681234bcb9e0fe14f488e7dfc", size = 799843, upload-time = "2026-05-09T23:12:16.892Z" }, + { url = "https://files.pythonhosted.org/packages/73/59/955734c803f59108deccba3597ae440c76b62a652733c0006e6243758420/regex-2026.5.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f079e50a0d3cc3cd5091fa9ff45869a2e6b2cd35895731edafb0327901a8d86d", size = 773610, upload-time = "2026-05-09T23:12:19.127Z" }, + { url = "https://files.pythonhosted.org/packages/68/8f/70c04a236d651c81881dac42ef8538bddda6121434509d0a22d9e601503b/regex-2026.5.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4ebe8f0b5ec5a5024dc4a4c59f444c4e9afc5f2abdbb8962065b75d27fb971f9", size = 781645, upload-time = "2026-05-09T23:12:20.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/96/05c7434d88185e5d27fe54aeb74df86bd77cd79f52f0b4eae54faa8fea70/regex-2026.5.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:97cf3bc1b7d7d2306772ec07366c80d9df00ff79e79cea32898883a646d2fae2", size = 854473, upload-time = "2026-05-09T23:12:22.465Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/6e3d8202d981f3117004bf341ee74893ba4ba8a9fbaf4b94615846550a08/regex-2026.5.9-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0f9eede6a5cbdc02d4978090186390936e1776a7d1359b21e41014c609880bcf", size = 763311, upload-time = "2026-05-09T23:12:24.351Z" }, + { url = "https://files.pythonhosted.org/packages/93/c7/e7737f1526b3fb32bd4c337fd6c71c3ebb5c8296fc34d11197e0955d2e35/regex-2026.5.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:01f0f5f55f4b64dacec85dc116d3c05fd23ad3ff037bbc73a2085775953c2611", size = 844593, upload-time = "2026-05-09T23:12:26.341Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/0daffb1a535bb39f422c3d200f4ab023c71110ad66a32b366bee708baba0/regex-2026.5.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1268eddd8486dc561d08eee1156e40aa3a8fe10f4bdec8fa653b455fcbffd12c", size = 789167, upload-time = "2026-05-09T23:12:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fc/294fe4fac4f2ed67207b17471815870c1c45b3a489e08e0ac96daea16ef6/regex-2026.5.9-cp311-cp311-win32.whl", hash = "sha256:8676474c07469d6f33dd1085ca2cd45f65785f32518f2b20e36d9953ca07f994", size = 266249, upload-time = "2026-05-09T23:12:30.141Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b0/8dce459f6245bcf8f6e9f23ac9569f1a0f15c131cc0745e82b43226204cf/regex-2026.5.9-cp311-cp311-win_amd64.whl", hash = "sha256:246de9d60aa3f8538b519834dd95cbf276ea263d6a7bd5a3666dc3fa0230505b", size = 278423, upload-time = "2026-05-09T23:12:31.676Z" }, + { url = "https://files.pythonhosted.org/packages/db/8d/f9aeff6ad63a3ef720386f2907e6d34a35a510a6e498ebad28b0fb3f6ab6/regex-2026.5.9-cp311-cp311-win_arm64.whl", hash = "sha256:d726ca3f0d76969bf1e8e477d160d3d666bbf999f6860bd314889e5345782046", size = 270420, upload-time = "2026-05-09T23:12:33.194Z" }, + { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" }, + { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" }, + { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" }, + { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" }, + { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" }, + { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, + { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, + { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, + { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, + { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, + { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, + { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, + { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, + { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, + { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, + { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, + { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -3714,6 +3905,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "reuse" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "click" }, + { name = "jinja2" }, + { name = "license-expression" }, + { name = "python-debian" }, + { name = "python-magic" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/35/298d9410b3635107ce586725cdfbca4c219c08d77a3511551f5e479a78db/reuse-6.2.0.tar.gz", hash = "sha256:4feae057a2334c9a513e6933cdb9be819d8b822f3b5b435a36138bd218897d23", size = 1615611, upload-time = "2025-10-27T15:25:46.336Z" } + [[package]] name = "rfc3339-validator" version = "0.1.4" @@ -3961,15 +4167,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.2.1" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] @@ -4075,6 +4281,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] +[[package]] +name = "tomlkit" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, +] + [[package]] name = "tqdm" version = "4.67.3" From 40c50d44f213276711c7482af95e30ab5f4506f4 Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:26:08 -0700 Subject: [PATCH 05/20] "Simplify CI/packaging for OSS: drop SonarQube for CodeQL, dedup tests & dev-deps" --- .github/workflows/codeql.yml | 39 +++++++ .github/workflows/quality-gates.yml | 63 +----------- .gitignore | 2 + README.md | 2 +- docs/code-quality-compliance.md | 2 +- docs/quality-security-gates.md | 103 +++++-------------- packages/connectors/fhir_cerner/__init__.py | 4 - packages/connectors/fhir_epic/__init__.py | 4 - packages/connectors/google_drive/__init__.py | 4 - packages/connectors/http_generic/__init__.py | 4 - packages/connectors/smtp/__init__.py | 4 - packages/connectors/stripe/__init__.py | 4 - pyproject.toml | 10 -- sonar-project.properties | 15 --- uv.lock | 19 +--- 15 files changed, 71 insertions(+), 208 deletions(-) create mode 100644 .github/workflows/codeql.yml delete mode 100644 packages/connectors/fhir_cerner/__init__.py delete mode 100644 packages/connectors/fhir_epic/__init__.py delete mode 100644 packages/connectors/google_drive/__init__.py delete mode 100644 packages/connectors/http_generic/__init__.py delete mode 100644 packages/connectors/smtp/__init__.py delete mode 100644 packages/connectors/stripe/__init__.py delete mode 100644 sonar-project.properties diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..a5afe1d --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,39 @@ + +## +## SPDX-FileCopyrightText: 2026 AOT Technologies +## SPDX-License-Identifier: Apache-2.0 +## + +name: CodeQL + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + schedule: + - cron: "0 6 * * 1" + +jobs: + analyze: + name: Analyze (Python) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: python + queries: security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:python" diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 8475c03..970e855 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -11,7 +11,7 @@ on: push: branches: [main, master] -# This workflow enforces Bandit, tests/coverage, and SonarQube. +# This workflow enforces Bandit SAST on every PR and push to main/master. jobs: bandit: @@ -47,64 +47,3 @@ jobs: if-no-files-found: error - name: Enforce high-severity Bandit gate run: uv run bandit -c pyproject.toml -r src --severity-level high - - test: - name: Tests and coverage - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Set up Python - uses: actions/setup-python@v5.3.0 - with: - python-version: "3.11" - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - cache-dependency-glob: | - pyproject.toml - uv.lock - - name: Install dependencies - run: uv sync --frozen --all-extras --dev - - name: Run tests (coverage.xml generated via pyproject addopts) - run: uv run pytest tests/ -v - - name: Generate SBOM - run: uv run cyclonedx-py environment -o sbom.json - - name: Upload coverage artifact - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 - with: - name: coverage-xml - path: coverage.xml - if-no-files-found: error - - name: Upload SBOM artifact - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 - with: - name: sbom - path: sbom.json - if-no-files-found: error - - sonar: - name: SonarQube analysis - runs-on: ubuntu-latest - needs: [bandit, test] - # Sonar scan requires repository secrets; skip gracefully when unavailable - # (e.g. PRs from forks where secrets are not exposed). - if: ${{ secrets.SONAR_TOKEN != '' && secrets.SONAR_HOST_URL != '' }} - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 - - name: Download coverage artifact - uses: actions/download-artifact@v4 - with: - name: coverage-xml - path: . - - name: SonarQube scan (wait for quality gate) - uses: SonarSource/sonarqube-scan-action@v5.3.1 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} - with: - args: > - -Dsonar.qualitygate.wait=true - -Dsonar.qualitygate.timeout=300 diff --git a/.gitignore b/.gitignore index ff654a1..50368c5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ build/ .pytest_cache +.mypy_cache/ +.ruff_cache/ __pycache__/ *.pyc *.egg-info/ diff --git a/README.md b/README.md index c558d93..cb482ef 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ For more detailed information, please refer to the following guides: - Individual connector MCP servers (ToolHive): [docs/mcp-servers.md](docs/mcp-servers.md) - Creating a new connector: [docs/connectors.md](docs/connectors.md) - Code quality/compliance (Ruff, Mypy, REUSE, pip-audit): [docs/code-quality-compliance.md](docs/code-quality-compliance.md) -- Quality/security gates (Bandit, SonarQube): [docs/quality-security-gates.md](docs/quality-security-gates.md) +- Quality/security gates (Bandit, CodeQL): [docs/quality-security-gates.md](docs/quality-security-gates.md) --- diff --git a/docs/code-quality-compliance.md b/docs/code-quality-compliance.md index e320977..a1d4e65 100644 --- a/docs/code-quality-compliance.md +++ b/docs/code-quality-compliance.md @@ -76,7 +76,7 @@ bash scripts/add-license-headers.sh 3. Verify freshness: `uv lock --check` 4. Commit both `pyproject.toml` and `uv.lock`. -`DEPENDENCIES.md` is a human-readable license inventory (from `pip-licenses`). `sbom.json` is a CycloneDX SBOM generated by the compliance script and uploaded as a CI artifact from quality gates. +`DEPENDENCIES.md` is a human-readable license inventory (from `pip-licenses`). `sbom.json` is a CycloneDX SBOM generated by the compliance script (`scripts/run-compliance-checks.sh`) and at release time via `.github/workflows/publish.yml`. ## Dependency inventory and compliance diff --git a/docs/quality-security-gates.md b/docs/quality-security-gates.md index 11b2d48..a6a6f53 100644 --- a/docs/quality-security-gates.md +++ b/docs/quality-security-gates.md @@ -1,10 +1,10 @@ -# SPDX-FileCopyrightText: 2026 AOT Technologies -# -# SPDX-License-Identifier: Apache-2.0 - +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + # Quality and security gates -This document defines how Node Wire enforces security scanning and SonarQube analysis in CI, plus the SonarQube Community Edition setup required for centralized reporting. +This document defines how Node Wire enforces security scanning in CI. This repository enforces security gates at both PR time and publish time. @@ -17,17 +17,24 @@ Runs on every pull request and on pushes to `main`/`master`. Required jobs: - `bandit`: writes `bandit-report.json` (with `--exit-zero` so low/medium findings do not fail the job before the gate), prints a log summary, uploads the artifact, then fails only on **high**-severity findings in the enforce step. -- `test`: runs `pytest`, produces `coverage.xml`, and uploads a CycloneDX `sbom.json` artifact. -- `sonar`: runs SonarQube scan and waits for quality gate result (runs after `bandit` and `test`). + +Workflow: `.github/workflows/codeql.yml` + +Runs GitHub CodeQL static analysis for Python on pull requests, pushes to `main`/`master`, and weekly (Mondays). No repository secrets are required. + +Workflow: `.github/workflows/pytest.yml` + +Runs the full test suite (Python 3.11 and 3.12 matrix) with coverage on every pull request and push to `main`/`master`. Workflow: `.github/workflows/lint.yml` also runs `lockfile-check` (`uv lock --check`) to fail PRs when `pyproject.toml` changes without an updated `uv.lock`. Required checks to add in branch protection: - `Lint and Type Check / Lockfile freshness` - - `Quality gates / Bandit security scan` -- `Quality gates / Tests and coverage` +- `CodeQL / Analyze (Python)` +- `CI – Pytest / Run pytest (Python 3.11)` +- `CI – Pytest / Run pytest (Python 3.12)` - `Python package security PR checks / Vulnerability scan (packages/runtime)` - `Python package security PR checks / Vulnerability scan (packages/connectors/http_generic)` - `Python package security PR checks / Vulnerability scan (packages/connectors/stripe)` @@ -62,7 +69,7 @@ uv run bandit -c pyproject.toml -r src --severity-level high uv run bandit -c pyproject.toml -r src -f json -o bandit-report.json --exit-zero python scripts/bandit_report_summary.py bandit-report.json -# Tests + coverage.xml (required by SonarQube) +# Tests + coverage (run via pytest.yml in CI) uv run pytest tests/ -v ``` @@ -83,29 +90,6 @@ pre-commit install pre-commit run --all-files ``` -## Local Sonar scan with Docker - -After generating `coverage.xml`, run scanner from the repository root: - -```bash -docker run --rm \ - -e SONAR_TOKEN=YOUR_TOKEN \ - -v "G:\SPACE\node-wire:/usr/src" \ - -w /usr/src \ - sonarsource/sonar-scanner-cli \ - -Dsonar.host.url=http://host.docker.internal:9000 \ - -Dsonar.token=YOUR_TOKEN -``` - -## SonarQube configuration - -The repository includes `sonar-project.properties` and CI expects these GitHub secrets: - -- `SONAR_HOST_URL` (example: `https://sonarqube.company.internal`) -- `SONAR_TOKEN` (project analysis token) - -For server setup and quality gate policy details, see this document's [SonarQube Community Edition setup](#sonarqube-community-edition-setup) section. - ## Bandit policy Bandit is configured in `pyproject.toml` under `[tool.bandit]`. @@ -116,7 +100,7 @@ By default, **Bandit exits with a non-zero status whenever it reports any findin CI splits responsibilities: -1. **JSON artifact + log summary** — `bandit ... -f json -o bandit-report.json --exit-zero` so the workflow always produces the report and runs `scripts/bandit_report_summary.py` for readable logs. Low/medium issues are visible here and in Sonar/import without failing the job. +1. **JSON artifact + log summary** — `bandit ... -f json -o bandit-report.json --exit-zero` so the workflow always produces the report and runs `scripts/bandit_report_summary.py` for readable logs. Low/medium issues are visible here without failing the job. 2. **Enforcement** — `bandit ... --severity-level high` fails the job only on high-severity findings (matches branch-protection intent). Locally, mirror CI with the commands in [Run checks locally](#run-checks-locally). @@ -137,53 +121,18 @@ bandit -c pyproject.toml -r src -f json -o bandit-baseline.json --exit-zero bandit -c pyproject.toml -r src --baseline bandit-baseline.json --severity-level high ``` -## SonarQube Community Edition setup - -### 1) Run SonarQube CE (example Docker) - -```bash -docker volume create sonarqube_data -docker volume create sonarqube_logs -docker volume create sonarqube_extensions - -docker run -d --name sonarqube \ - -p 9000:9000 \ - -v sonarqube_data:/opt/sonarqube/data \ - -v sonarqube_logs:/opt/sonarqube/logs \ - -v sonarqube_extensions:/opt/sonarqube/extensions \ - sonarqube:lts-community -``` - -For production, place SonarQube behind HTTPS/reverse proxy and persistent backup strategy. - -### 2) Create project and token - -1. Open SonarQube UI (`http://:9000`). -2. Create project key `node-wire` (or update `sonar-project.properties` if using a different key). -3. Generate project analysis token. - -### 3) Configure GitHub secrets - -In repository settings, add: - -- `SONAR_HOST_URL` -- `SONAR_TOKEN` - -### 4) Configure quality gate - -Create or update a quality gate to enforce at minimum: +## SBOM generation -- No new blocker issues. -- No new critical vulnerabilities. -- Coverage on new code >= 80%. +CycloneDX SBOM (`sbom.json`) is generated by: -Attach the gate to the Node Wire project. +- `scripts/run-compliance-checks.sh` for local compliance runs. +- `.github/workflows/publish.yml` at release time. ## Acceptance criteria mapping -- Security scan runs on every PR: enforced by `quality-gates.yml` (Bandit). +- Security scan runs on every PR: enforced by `quality-gates.yml` (Bandit) and `codeql.yml` (CodeQL). - Builds fail on high-severity Bandit findings: Bandit gate in CI. -- SonarQube dashboard visible: SonarQube CE project + scanner upload from CI. -- Coverage visible in SonarQube: `pytest-cov` generates `coverage.xml`, scanner consumes it via `sonar.python.coverage.reportPaths`. +- Static analysis visible in GitHub Security tab: CodeQL upload from CI. +- Tests run on every PR: enforced by `pytest.yml` (3.11 + 3.12 matrix). - Developers run checks locally: documented commands and pre-commit (Bandit). -- Config version-controlled: `pyproject.toml`, `.pre-commit-config.yaml`, `sonar-project.properties`, workflow file. +- Config version-controlled: `pyproject.toml`, `.pre-commit-config.yaml`, workflow files. diff --git a/packages/connectors/fhir_cerner/__init__.py b/packages/connectors/fhir_cerner/__init__.py deleted file mode 100644 index 39bdade..0000000 --- a/packages/connectors/fhir_cerner/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# diff --git a/packages/connectors/fhir_epic/__init__.py b/packages/connectors/fhir_epic/__init__.py deleted file mode 100644 index 39bdade..0000000 --- a/packages/connectors/fhir_epic/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# diff --git a/packages/connectors/google_drive/__init__.py b/packages/connectors/google_drive/__init__.py deleted file mode 100644 index 39bdade..0000000 --- a/packages/connectors/google_drive/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# diff --git a/packages/connectors/http_generic/__init__.py b/packages/connectors/http_generic/__init__.py deleted file mode 100644 index 39bdade..0000000 --- a/packages/connectors/http_generic/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# diff --git a/packages/connectors/smtp/__init__.py b/packages/connectors/smtp/__init__.py deleted file mode 100644 index 39bdade..0000000 --- a/packages/connectors/smtp/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# diff --git a/packages/connectors/stripe/__init__.py b/packages/connectors/stripe/__init__.py deleted file mode 100644 index 39bdade..0000000 --- a/packages/connectors/stripe/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 -# diff --git a/pyproject.toml b/pyproject.toml index 1fea8b6..88630a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,15 +63,6 @@ nw-salesforce = "agents.salesforce_mcp:main" nw-slack = "agents.slack_mcp:main" [project.optional-dependencies] -dev = [ - "pytest>=8.0.0", - "pytest-asyncio>=0.23.0", - "pytest-cov>=5.0.0", - "ruff>=0.3.5", - "mypy>=1.9.0", - "bandit[toml]>=1.7.9", - "pre-commit>=4.0.0", -] agents = [ "mcp>=1.6.0", # Official Python MCP SDK (includes FastMCP) "groq>=0.9.0", # Groq LLM SDK @@ -107,7 +98,6 @@ dev = [ "pytest-asyncio>=1.3.0", "pytest-cov>=7.1.0", "pip-licenses>=5.0.0", - "bandit>=1.7.0", "pip-audit>=2.7.0", "ruff>=0.3.5", "mypy>=1.9.0", diff --git a/sonar-project.properties b/sonar-project.properties deleted file mode 100644 index 860a72d..0000000 --- a/sonar-project.properties +++ /dev/null @@ -1,15 +0,0 @@ -# SPDX-FileCopyrightText: 2026 AOT Technologies -# SPDX-License-Identifier: Apache-2.0 - -sonar.projectKey=node-wire -sonar.projectName=Node Wire -sonar.sourceEncoding=UTF-8 -sonar.python.version=3.11 - -sonar.sources=src -sonar.tests=tests - -sonar.exclusions=**/__pycache__/**,**/*.pyc,htmlcov/**,dist/**,playground/**,grafana/** -sonar.test.inclusions=tests/**/*.py - -sonar.python.coverage.reportPaths=coverage.xml diff --git a/uv.lock b/uv.lock index c333e9d..ae9acad 100644 --- a/uv.lock +++ b/uv.lock @@ -2184,15 +2184,6 @@ agents = [ { name = "mcp" }, { name = "openai" }, ] -dev = [ - { name = "bandit" }, - { name = "mypy" }, - { name = "pre-commit" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-cov" }, - { name = "ruff" }, -] [package.dev-dependencies] dev = [ @@ -2215,7 +2206,6 @@ dev = [ requires-dist = [ { name = "aiosmtplib", specifier = ">=3.0.1" }, { name = "anthropic", marker = "extra == 'agents'", specifier = ">=0.28.0" }, - { name = "bandit", extras = ["toml"], marker = "extra == 'dev'", specifier = ">=1.7.9" }, { name = "email-validator", specifier = ">=2.0.0" }, { name = "fastapi", specifier = ">=0.111.0" }, { name = "google-api-python-client", specifier = ">=2.100.0" }, @@ -2226,32 +2216,25 @@ requires-dist = [ { name = "grpcio-tools", specifier = ">=1.62.0" }, { name = "httpx", extras = ["http2"], specifier = ">=0.27.0,<0.28.0" }, { name = "mcp", marker = "extra == 'agents'", specifier = ">=1.6.0" }, - { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.9.0" }, { name = "openai", marker = "extra == 'agents'", specifier = ">=1.30.0" }, { name = "opentelemetry-api", specifier = ">=1.24.0" }, { name = "opentelemetry-exporter-otlp", specifier = ">=1.24.0" }, { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.45b0" }, { name = "opentelemetry-sdk", specifier = ">=1.24.0" }, - { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "pybreaker", specifier = ">=1.0.0,<2.0.0" }, { name = "pydantic", specifier = ">=2.6.0,<3.0.0" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.8.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "pyyaml", specifier = ">=6.0.1" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.5" }, { name = "stripe", specifier = ">=10.0.0" }, { name = "tenacity", specifier = ">=8.2.0" }, { name = "traceloop-sdk", specifier = ">=0.53.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, ] -provides-extras = ["dev", "agents"] +provides-extras = ["agents"] [package.metadata.requires-dev] dev = [ - { name = "bandit", specifier = ">=1.7.0" }, { name = "bandit", extras = ["toml"], specifier = ">=1.7.9" }, { name = "cyclonedx-bom", specifier = "==4.6.1" }, { name = "licenseheaders", specifier = ">=0.8.8" }, From 9911a8279da73f896d2d25151b893836a72a468e Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:21:34 -0700 Subject: [PATCH 06/20] Increased Test coverage for google drive, salesforce, stripe connectors --- tests/conftest.py | 9 +- tests/fixtures/connectors_for_tests.yaml | 10 +- tests/test_connectors_basic.py | 24 +++- tests/test_google_drive.py | 54 ++++++++ tests/test_google_drive_action_spec.py | 81 ++++++++++++ tests/test_google_drive_build_client.py | 156 +++++++++++++++++++++++ tests/test_mcp_manifest_conformance.py | 34 +++++ tests/test_rest_connector_dispatch.py | 136 ++++++++++++++++++++ tests/test_salesforce.py | 18 +++ tests/test_slack_connector.py | 89 +++++++++++++ tests/test_stripe.py | 65 ++++++++++ 11 files changed, 665 insertions(+), 11 deletions(-) create mode 100644 tests/test_google_drive_build_client.py create mode 100644 tests/test_rest_connector_dispatch.py diff --git a/tests/conftest.py b/tests/conftest.py index a8bba11..51a66d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,11 +21,12 @@ _TESTS_ROOT = Path(__file__).resolve().parent # Ensure tests can import app.py which builds dynamic routes via factory (needs allowed connectors to not crash M3 fail-fast) -os.environ["NW_ALLOWED_CONNECTORS"] = "http_generic,smtp,stripe,google_drive,fhir_epic,fhir_cerner" +os.environ["NW_ALLOWED_CONNECTORS"] = ( + "http_generic,smtp,stripe,google_drive,fhir_epic,fhir_cerner,salesforce,slack" +) # Skip REST bind dotenv so repo `.env` cannot override the allowlist above during collection/import. os.environ["NW_REST_LOAD_DOTENV"] = "false" -# Use a connector config where optional connectors (e.g. slack, salesforce) are disabled so CI and -# devs without those packages still match the narrow allowlist (see tests/fixtures/connectors_for_tests.yaml). +# Test fixture enables all eight publishable connectors (see tests/fixtures/connectors_for_tests.yaml). os.environ["NW_CONFIG_PATH"] = str(_TESTS_ROOT / "fixtures" / "connectors_for_tests.yaml") os.environ["NW_JWT_AUDIENCE"] = "node-wire-test" os.environ["NW_JWT_ISSUER"] = "node-wire-test-issuer" @@ -44,6 +45,8 @@ def _preload_connector_logic_modules() -> None: "node_wire_google_drive.logic", "node_wire_fhir_epic.logic", "node_wire_fhir_cerner.logic", + "node_wire_salesforce.logic", + "node_wire_slack.logic", ): try: importlib.import_module(mod) diff --git a/tests/fixtures/connectors_for_tests.yaml b/tests/fixtures/connectors_for_tests.yaml index 29c7cd4..b7f75bb 100644 --- a/tests/fixtures/connectors_for_tests.yaml +++ b/tests/fixtures/connectors_for_tests.yaml @@ -3,11 +3,7 @@ ## SPDX-License-Identifier: Apache-2.0 ## -# Test fixture: mirrors ../config/connectors.yaml but optional connectors not on -# the pytest allowlist are disabled (slack, salesforce). -# Enabling them here would fail ConnectorFactory.load() when not registered -# (NW_ALLOWED_CONNECTORS / missing package). -# +# Test fixture: mirrors ../config/connectors.yaml with all eight publishable connectors enabled. # SECURITY RULE: This file must never contain secrets. connectors: @@ -77,7 +73,7 @@ connectors: - system/DocumentReference.write salesforce: - enabled: false + enabled: true exposed_via: ["rest", "grpc", "mcp"] auth: provider: oauth2 @@ -88,7 +84,7 @@ connectors: refresh_token_secret: SALESFORCE_REFRESH_TOKEN slack: - enabled: false + enabled: true exposed_via: ["rest", "grpc", "mcp"] auth: provider: static_token diff --git a/tests/test_connectors_basic.py b/tests/test_connectors_basic.py index b5da862..cc21e67 100644 --- a/tests/test_connectors_basic.py +++ b/tests/test_connectors_basic.py @@ -23,7 +23,8 @@ def get_secret(self, key: str) -> str: def test_auto_register_runs_without_error(monkeypatch): monkeypatch.setenv( - "NW_ALLOWED_CONNECTORS", "fhir_cerner,fhir_epic,google_drive,smtp,stripe,http_generic" + "NW_ALLOWED_CONNECTORS", + "fhir_cerner,fhir_epic,google_drive,smtp,stripe,http_generic,salesforce,slack", ) imported = auto_register() if not imported: @@ -59,3 +60,24 @@ def test_salesforce_connector_instantiation_only(): connector = BaseConnector.get_registry()["salesforce"](secret_provider=provider) assert connector.connector_id == "salesforce" assert "create_lead" in connector._action_registry + + +_ALL_EIGHT_CONNECTOR_IDS = ( + "http_generic", + "smtp", + "stripe", + "google_drive", + "fhir_epic", + "fhir_cerner", + "salesforce", + "slack", +) + + +def test_factory_loads_all_eight_connectors() -> None: + from bindings.factory import ConnectorFactory + + factory = ConnectorFactory() + factory.load() + for connector_id in _ALL_EIGHT_CONNECTOR_IDS: + assert factory.get_for_protocol(connector_id, "rest") is not None, connector_id diff --git a/tests/test_google_drive.py b/tests/test_google_drive.py index 699c89c..e1bc456 100644 --- a/tests/test_google_drive.py +++ b/tests/test_google_drive.py @@ -123,3 +123,57 @@ def test_google_drive_schema_discriminator_validation(): with pytest.raises(ValidationError): GoogleDriveOperationInput.model_validate({"action": "files.unknown", "file_id": "abc123"}) + + +@pytest.mark.parametrize( + ("action", "payload", "status", "expected_exception"), + [ + ( + "files.upload", + { + "name": "upload.txt", + "mime_type": "text/plain", + "content": "hello", + }, + 403, + GoogleDriveAuthError, + ), + ( + "permissions.create", + { + "file_id": "f1", + "role": "reader", + "type": "user", + "email_address": "a@b.com", + }, + 404, + GoogleDriveBusinessError, + ), + ], +) +def test_google_drive_execute_translates_http_errors( + action: str, + payload: dict, + status: int, + expected_exception: type[Exception], +) -> None: + from googleapiclient.errors import HttpError + + connector = _connector() + params = GoogleDriveOperationInput.model_validate({"action": action, **payload}) + + async def _raise_http_error(*_args: object, **_kwargs: object) -> None: + resp = MagicMock() + resp.status = status + resp.reason = "upstream error" + raise HttpError(resp, b"error") + + with ( + patch.object(connector, "get_client", return_value=MagicMock()), + patch( + "node_wire_google_drive.logic.execute_spec_in_thread", + side_effect=_raise_http_error, + ), + ): + with pytest.raises(expected_exception): + asyncio.run(connector.internal_execute(params, trace_id="test-trace")) diff --git a/tests/test_google_drive_action_spec.py b/tests/test_google_drive_action_spec.py index e498159..eff52bb 100644 --- a/tests/test_google_drive_action_spec.py +++ b/tests/test_google_drive_action_spec.py @@ -134,3 +134,84 @@ def test_permissions_create_excludes_empty_optional_fields(): body = kwargs["body"] assert "emailAddress" not in body assert "domain" not in body + + +def test_files_get_returns_metadata(): + connector = _connector() + params = GoogleDriveOperationInput.model_validate( + {"action": "files.get", "file_id": "fid-1", "fields": "id,name"} + ) + + drive = MagicMock() + files_api = drive.files.return_value + files_api.get.return_value.execute.return_value = {"id": "fid-1", "name": "doc.txt"} + + with patch.object(connector, "get_client", return_value=drive): + result = asyncio.run(connector.internal_execute(params, trace_id="t")) + + assert result.raw == {"id": "fid-1", "name": "doc.txt"} + files_api.get.assert_called_once_with( + fileId="fid-1", + fields="id,name", + supportsAllDrives=True, + ) + + +def test_files_update_sends_body_and_parent_changes(): + connector = _connector() + params = GoogleDriveOperationInput.model_validate( + { + "action": "files.update", + "file_id": "fid-2", + "name": "renamed.txt", + "mime_type": "text/plain", + "add_parents": ["p1"], + "remove_parents": ["p0"], + } + ) + + drive = MagicMock() + files_api = drive.files.return_value + files_api.update.return_value.execute.return_value = {"id": "fid-2", "name": "renamed.txt"} + + with patch.object(connector, "get_client", return_value=drive): + result = asyncio.run(connector.internal_execute(params, trace_id="t")) + + assert result.raw == {"id": "fid-2", "name": "renamed.txt"} + files_api.update.assert_called_once_with( + fileId="fid-2", + body={"name": "renamed.txt", "mimeType": "text/plain"}, + addParents="p1", + removeParents="p0", + supportsAllDrives=True, + ) + + +def test_files_upload_uses_content_and_media_body(): + connector = _connector() + params = GoogleDriveOperationInput.model_validate( + { + "action": "files.upload", + "name": "upload.txt", + "mime_type": "text/plain", + "content": "hello", + } + ) + + drive = MagicMock() + files_api = drive.files.return_value + files_api.create.return_value.execute.return_value = { + "id": "up-1", + "name": "upload.txt", + "webViewLink": "https://drive.google.com/file/d/up-1", + } + + with patch.object(connector, "get_client", return_value=drive): + result = asyncio.run(connector.internal_execute(params, trace_id="t")) + + assert result.raw["id"] == "up-1" + _, kwargs = files_api.create.call_args + assert kwargs["body"] == {"name": "upload.txt", "mimeType": "text/plain"} + assert kwargs["media_body"] is not None + assert kwargs["fields"] == "id, name, webViewLink" + assert kwargs["supportsAllDrives"] is True diff --git a/tests/test_google_drive_build_client.py b/tests/test_google_drive_build_client.py new file mode 100644 index 0000000..4768f92 --- /dev/null +++ b/tests/test_google_drive_build_client.py @@ -0,0 +1,156 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from node_wire_google_drive.logic import GoogleDriveConnector +from node_wire_runtime import SecretProvider +from node_wire_runtime.auth.no_auth import NoAuthProvider + +_DRIVE_SCOPE = ["https://www.googleapis.com/auth/drive"] + + +class MockSecretProvider(SecretProvider): + def __init__(self, *, sa_json: str | None = None) -> None: + self._sa_json = sa_json or '{"type":"service_account","project_id":"dummy"}' + + def get_secret(self, key: str) -> str: + if key == "GOOGLE_DRIVE_SA_JSON": + return self._sa_json + raise KeyError(key) + + +def _connector( + *, + auth_provider: object | None = None, + secret_provider: SecretProvider | None = None, +) -> GoogleDriveConnector: + return GoogleDriveConnector( + secret_provider=secret_provider or MockSecretProvider(), + auth_provider=auth_provider or NoAuthProvider(), + ) + + +def _auth_provider_with_creds(fake_creds: object) -> MagicMock: + provider = MagicMock() + provider.get_client_credentials = AsyncMock(return_value=fake_creds) + return provider + + +@contextmanager +def _patch_build(): + with patch( + "node_wire_google_drive.logic.build", return_value=MagicMock(name="drive") + ) as mock_build: + yield mock_build + + +def test_build_client_uses_auth_provider_on_idle_event_loop() -> None: + fake_creds = object() + connector = _connector(auth_provider=_auth_provider_with_creds(fake_creds)) + mock_loop = MagicMock() + mock_loop.is_running.return_value = False + mock_loop.run_until_complete.return_value = fake_creds + + with ( + patch("asyncio.get_event_loop", return_value=mock_loop), + _patch_build() as mock_build, + ): + client = connector.build_client() + + assert client is mock_build.return_value + mock_loop.run_until_complete.assert_called_once() + mock_build.assert_called_once_with("drive", "v3", credentials=fake_creds) + + +@pytest.mark.asyncio +async def test_build_client_uses_auth_provider_when_event_loop_is_running() -> None: + fake_creds = object() + connector = _connector(auth_provider=_auth_provider_with_creds(fake_creds)) + + with _patch_build() as mock_build: + client = connector.build_client() + + assert client is mock_build.return_value + mock_build.assert_called_once_with("drive", "v3", credentials=fake_creds) + connector.auth_provider.get_client_credentials.assert_awaited_once() + + +def test_build_client_uses_asyncio_run_when_get_event_loop_raises() -> None: + fake_creds = object() + connector = _connector(auth_provider=_auth_provider_with_creds(fake_creds)) + + with ( + patch("asyncio.get_event_loop", side_effect=RuntimeError("no loop")), + patch("asyncio.run", return_value=fake_creds) as mock_run, + _patch_build() as mock_build, + ): + client = connector.build_client() + + assert client is mock_build.return_value + mock_run.assert_called_once() + mock_build.assert_called_once_with("drive", "v3", credentials=fake_creds) + + +def test_build_client_falls_back_to_inline_sa_json_when_auth_returns_none() -> None: + connector = _connector() + json_creds = MagicMock(name="json_creds") + sa_info = {"type": "service_account", "project_id": "dummy"} + + with ( + patch( + "google.oauth2.service_account.Credentials.from_service_account_info", + return_value=json_creds, + ) as mock_from_info, + patch( + "google.oauth2.service_account.Credentials.from_service_account_file" + ) as mock_from_file, + _patch_build() as mock_build, + ): + client = connector.build_client() + + assert client is mock_build.return_value + mock_from_info.assert_called_once_with(sa_info, scopes=_DRIVE_SCOPE) + mock_from_file.assert_not_called() + mock_build.assert_called_once_with("drive", "v3", credentials=json_creds) + + +def test_build_client_falls_back_to_sa_file_path_when_json_parse_fails() -> None: + connector = _connector(secret_provider=MockSecretProvider(sa_json="/path/to/sa.json")) + file_creds = MagicMock(name="file_creds") + + with ( + patch( + "google.oauth2.service_account.Credentials.from_service_account_info", + ) as mock_from_info, + patch( + "google.oauth2.service_account.Credentials.from_service_account_file", + return_value=file_creds, + ) as mock_from_file, + _patch_build() as mock_build, + ): + client = connector.build_client() + + assert client is mock_build.return_value + mock_from_info.assert_not_called() + mock_from_file.assert_called_once_with("/path/to/sa.json", scopes=_DRIVE_SCOPE) + mock_build.assert_called_once_with("drive", "v3", credentials=file_creds) + + +def test_get_client_caches_build_client_result() -> None: + connector = _connector() + mock_drive = MagicMock(name="drive") + + with patch.object(connector, "build_client", return_value=mock_drive) as mock_build_client: + first = connector.get_client() + second = connector.get_client() + + assert first is mock_drive + assert second is mock_drive + mock_build_client.assert_called_once() diff --git a/tests/test_mcp_manifest_conformance.py b/tests/test_mcp_manifest_conformance.py index 0933771..28a601e 100644 --- a/tests/test_mcp_manifest_conformance.py +++ b/tests/test_mcp_manifest_conformance.py @@ -8,6 +8,17 @@ from node_wire_runtime.manifest import MCP_MANIFEST_CONTRACT_VERSION +ALL_CONNECTOR_IDS = ( + "http_generic", + "smtp", + "stripe", + "google_drive", + "fhir_epic", + "fhir_cerner", + "salesforce", + "slack", +) + def test_manifest_contract_version_is_exported() -> None: assert MCP_MANIFEST_CONTRACT_VERSION @@ -29,6 +40,29 @@ def test_per_connector_tool_names_are_subsets_of_unified() -> None: assert "google_drive.files.upload" in drive +def test_all_eight_connectors_tools_are_subsets_of_unified() -> None: + full = {t["name"] for t in _tools_from_server()} + for connector_id in ALL_CONNECTOR_IDS: + subset = {t["name"] for t in _tools_from_server(connector_ids=[connector_id])} + expected = {name for name in full if name.startswith(f"{connector_id}.")} + assert subset == expected, connector_id + + +def test_unified_manifest_includes_representative_tools_for_each_connector() -> None: + full = {t["name"] for t in _tools_from_server()} + expected_tools = { + "http_generic.request", + "smtp.send_email", + "stripe.charge", + "google_drive.files.list", + "fhir_epic.read_patient", + "fhir_cerner.read_patient", + "salesforce.create_contact", + "slack.post_message", + } + assert expected_tools.issubset(full) + + def _tools_from_server(connector_ids: list[str] | None = None) -> list[dict]: from bindings.mcp_server.server import McpServer diff --git a/tests/test_rest_connector_dispatch.py b/tests/test_rest_connector_dispatch.py new file mode 100644 index 0000000..31ceb97 --- /dev/null +++ b/tests/test_rest_connector_dispatch.py @@ -0,0 +1,136 @@ +# +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# +"""REST dispatch smoke tests for all eight publishable connectors.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi.testclient import TestClient + +from bindings.rest_api.app import app, get_factory +from node_wire_runtime.models import ConnectorResponse + +_ALL_EIGHT_CONNECTOR_IDS = ( + "http_generic", + "smtp", + "stripe", + "google_drive", + "fhir_epic", + "fhir_cerner", + "salesforce", + "slack", +) + +_REST_SMOKE_CASES = [ + pytest.param( + "http_generic", + "request", + "/connectors/http_generic/request", + {"method": "GET", "url": "https://example.com"}, + id="http_generic", + ), + pytest.param( + "smtp", + "send_email", + "/connectors/smtp/send_email", + { + "from_email": "a@example.com", + "to": ["b@example.com"], + "subject": "s", + "body": "hi", + }, + id="smtp", + ), + pytest.param( + "stripe", + "charge", + "/connectors/stripe/charge", + {"amount": 100, "currency": "usd", "source": "tok_visa"}, + id="stripe", + ), + pytest.param( + "google_drive", + "files.list", + "/connectors/google_drive/files.list", + {"action": "files.list", "page_size": 1}, + id="google_drive", + ), + pytest.param( + "fhir_epic", + "read_patient", + "/connectors/fhir_epic/read_patient", + {"resource_id": "123"}, + id="fhir_epic", + ), + pytest.param( + "fhir_cerner", + "read_patient", + "/connectors/fhir_cerner/read_patient", + {"resource_id": "123"}, + id="fhir_cerner", + ), + pytest.param( + "salesforce", + "create_contact", + "/connectors/salesforce/create_contact", + {"LastName": "Doe"}, + id="salesforce", + ), + pytest.param( + "slack", + "post_message", + "/connectors/slack/post_message", + {"channel": "C0TEST123", "message": "hi"}, + id="slack", + ), +] + + +def _stub_connector(response: ConnectorResponse) -> MagicMock: + connector = MagicMock() + connector.run = AsyncMock(return_value=response) + return connector + + +@pytest.mark.parametrize( + ("connector_id", "action", "route_path", "payload"), + _REST_SMOKE_CASES, +) +def test_rest_dispatch_smoke_per_connector( + connector_id: str, + action: str, + route_path: str, + payload: dict, +) -> None: + mock_factory = MagicMock() + mock_factory.get_for_protocol.return_value = _stub_connector( + ConnectorResponse(success=True, data={"ok": True}, trace_id=f"t-{connector_id}") + ) + app.dependency_overrides[get_factory] = lambda: mock_factory + try: + client = TestClient(app) + response = client.post(route_path, json=payload) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert response.json()["success"] is True + mock_factory.get_for_protocol.assert_called_with(connector_id, "rest", action=action) + + +@pytest.mark.parametrize( + ("connector_id", "action", "route_path", "_payload"), + _REST_SMOKE_CASES, +) +def test_rest_routes_registered_for_all_connectors( + connector_id: str, + action: str, + route_path: str, + _payload: dict, +) -> None: + registered_paths = {getattr(route, "path", None) for route in app.routes} + assert route_path in registered_paths, f"missing REST route for {connector_id}.{action}" diff --git a/tests/test_salesforce.py b/tests/test_salesforce.py index 64c8e0e..9aa241f 100644 --- a/tests/test_salesforce.py +++ b/tests/test_salesforce.py @@ -229,6 +229,24 @@ async def test_salesforce_read_lead_happy_path(): assert result.data["LastName"] == "Smith" +@pytest.mark.asyncio +async def test_salesforce_read_contact_happy_path(): + connector = _connector() + params = ReadContactInput(record_id="003123456789012") + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.content = b'{"Id": "003123456789012", "LastName": "Doe"}' + mock_response.json.return_value = {"Id": "003123456789012", "LastName": "Doe"} + + with patch("httpx.AsyncClient.request", return_value=mock_response): + result = await connector.read_contact(params, trace_id="test-trace") + + assert result.success is True + assert result.resource_id == "003123456789012" + assert result.data["LastName"] == "Doe" + + @pytest.mark.asyncio async def test_salesforce_update_lead_happy_path(): connector = _connector() diff --git a/tests/test_slack_connector.py b/tests/test_slack_connector.py index 53bc732..be219e6 100644 --- a/tests/test_slack_connector.py +++ b/tests/test_slack_connector.py @@ -42,11 +42,14 @@ SlackMessageError, SlackPermissionError, SlackRateLimitError, + SlackUploadError, ) from node_wire_slack.logic import ( SlackConnector, _complete_upload, + _get_upload_url, _resolve_blocks, + _upload_bytes, ) import node_wire_slack.registration # noqa: F401 @@ -540,6 +543,92 @@ def emit(self, record: logging.LogRecord) -> None: assert getattr(warnings[0], "error_type", None) == "ConnectError" +@pytest.mark.asyncio +async def test_upload_file_filepath_happy_path( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + attachments_dir = tmp_path / "attachments" + attachments_dir.mkdir() + upload_path = attachments_dir / "note.txt" + upload_path.write_bytes(b"file data") + monkeypatch.setattr("node_wire_slack.logic._ATTACHMENTS_DIR", str(attachments_dir)) + + connector = _make_connector() + file_id = "F0TESTFILE" + complete_response = {"ok": True, "files": [{"id": file_id}]} + + with ( + patch( + "node_wire_slack.logic._get_upload_url", + new=AsyncMock(return_value=("https://upload.slack.com/test", file_id)), + ), + patch("node_wire_slack.logic._upload_bytes", new=AsyncMock(return_value=None)), + patch( + "node_wire_slack.logic._complete_upload", new=AsyncMock(return_value=complete_response) + ), + ): + result = await connector.run( + { + "action": "upload_file", + "channel": _CHANNEL, + "filepath": str(upload_path), + } + ) + + assert result.success is True + assert result.data["file_id"] == file_id + + +@pytest.mark.asyncio +async def test_get_upload_url_missing_fields_raises() -> None: + class FakeResponse: + status_code = 200 + + def json(self) -> dict[str, object]: + return {"ok": True, "file_id": "F1"} + + class FakeAsyncClient: + def __init__(self, timeout: float) -> None: + self.timeout = timeout + + async def __aenter__(self) -> "FakeAsyncClient": + return self + + async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None: + return None + + async def post(self, *args: object, **kwargs: object) -> FakeResponse: + return FakeResponse() + + with patch("node_wire_slack.logic.httpx.AsyncClient", new=FakeAsyncClient): + with pytest.raises(SlackUploadError, match="upload_url or file_id"): + await _get_upload_url(_FAKE_TOKEN, "test.txt", 8) + + +@pytest.mark.asyncio +async def test_upload_bytes_non_200_raises_slack_upload_error() -> None: + class FakeResponse: + status_code = 500 + + class FakeAsyncClient: + def __init__(self, timeout: float) -> None: + self.timeout = timeout + + async def __aenter__(self) -> "FakeAsyncClient": + return self + + async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None: + return None + + async def post(self, *args: object, **kwargs: object) -> FakeResponse: + return FakeResponse() + + with patch("node_wire_slack.logic.httpx.AsyncClient", new=FakeAsyncClient): + with pytest.raises(SlackUploadError, match="HTTP 500"): + await _upload_bytes("https://upload.slack.com/test", b"data") + + def test_default_timeout_honors_env(monkeypatch: pytest.MonkeyPatch) -> None: """Q-7: _DEFAULT_TIMEOUT is configurable via NW_SLACK_TIMEOUT / NW_TIMEOUT.""" import importlib diff --git a/tests/test_stripe.py b/tests/test_stripe.py index 6f077c3..692876f 100644 --- a/tests/test_stripe.py +++ b/tests/test_stripe.py @@ -7,9 +7,11 @@ from unittest.mock import MagicMock, patch import pytest +import stripe from pydantic import ValidationError from node_wire_runtime import SecretProvider +from node_wire_runtime.models import ErrorCategory from node_wire_stripe.logic import StripeConnector from node_wire_stripe.schema import ( CancelSubscriptionInput, @@ -229,3 +231,66 @@ def test_stripe_error_mapping(): ErrorCategory.AUTH, "STRIPE_AUTH_ERROR", ) + + +# --------------------------------------------------------------------------- +# Runtime error mapping via run() +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_stripe_run_maps_card_error_to_business(): + connector = _connector() + card_error = stripe.error.CardError( + message="Your card was declined.", + param="number", + code="card_declined", + http_status=402, + ) + + with patch("stripe.Charge.create", side_effect=card_error): + result = await connector.run( + {"action": "charge", "amount": 100, "currency": "usd", "source": "tok_visa"} + ) + + assert result.success is False + assert result.error_category == ErrorCategory.BUSINESS + assert result.error_code == "STRIPE_CARD_ERROR" + + +@pytest.mark.asyncio +async def test_stripe_run_maps_rate_limit_to_retryable(): + connector = _connector() + rate_error = stripe.error.RateLimitError( + message="Rate limit", + http_status=429, + code="rate_limit", + ) + + with patch("stripe.Charge.create", side_effect=rate_error): + result = await connector.run( + {"action": "charge", "amount": 100, "currency": "usd", "source": "tok_visa"} + ) + + assert result.success is False + assert result.error_category == ErrorCategory.RETRYABLE + assert result.error_code == "STRIPE_RATE_LIMIT" + + +@pytest.mark.asyncio +async def test_stripe_run_maps_authentication_error_to_auth(): + connector = _connector() + auth_error = stripe.error.AuthenticationError( + message="Invalid API Key", + http_status=401, + code="api_key_invalid", + ) + + with patch("stripe.Charge.create", side_effect=auth_error): + result = await connector.run( + {"action": "charge", "amount": 100, "currency": "usd", "source": "tok_visa"} + ) + + assert result.success is False + assert result.error_category == ErrorCategory.AUTH + assert result.error_code == "STRIPE_AUTH_ERROR" From 6c26cfdb199d614ee779287ddfd6f1122491366d Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:36:29 -0700 Subject: [PATCH 07/20] Prepare 1.0.0 stable release with frozen public API and 80% coverage gate. Bump all packages to 1.0.0, document versioning/governance, and add connector unit tests for Stripe, FHIR, Slack, HTTP, SMTP, and Salesforce error paths to meet the new fail_under threshold. --- .github/PULL_REQUEST_TEMPLATE.md | 1 + .github/dependabot.yml | 28 ++ .github/workflows/dco.yml | 64 +++ .github/workflows/security-pr.yml | 2 +- CHANGELOG.md | 20 + CONTRIBUTING.md | 68 ++- GOVERNANCE.md | 42 ++ README.md | 22 +- ROADMAP.md | 34 ++ SECURITY.md | 8 +- SUPPORT.md | 35 ++ docs/connectors.md | 6 +- docs/public-api.md | 73 ++++ docs/versioning.md | 69 +++ .../connectors/fhir_cerner/pyproject.toml | 4 +- packages/connectors/fhir_epic/pyproject.toml | 4 +- .../connectors/google_drive/pyproject.toml | 4 +- .../connectors/http_generic/pyproject.toml | 4 +- packages/connectors/salesforce/pyproject.toml | 4 +- packages/connectors/slack/pyproject.toml | 4 +- packages/connectors/smtp/pyproject.toml | 4 +- packages/connectors/stripe/pyproject.toml | 4 +- packages/runtime/pyproject.toml | 2 +- pyproject.toml | 8 +- src/bindings/factory.py | 7 +- src/node_wire_runtime/__init__.py | 6 +- src/node_wire_runtime/base_connector.py | 9 +- src/node_wire_runtime/connector_registry.py | 2 +- tests/test_connectors_io.py | 108 +++++ tests/test_fhir_cerner.py | 237 ++++++++++ tests/test_fhir_epic.py | 403 ++++++++++++++++++ tests/test_google_drive.py | 7 + tests/test_salesforce.py | 18 + tests/test_slack_connector.py | 259 +++++++++++ tests/test_smtp_relay.py | 111 +++++ tests/test_stripe.py | 218 ++++++++++ uv.lock | 2 +- 37 files changed, 1855 insertions(+), 46 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/dco.yml create mode 100644 GOVERNANCE.md create mode 100644 ROADMAP.md create mode 100644 SUPPORT.md create mode 100644 docs/public-api.md create mode 100644 docs/versioning.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 84164ee..12612fb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -26,3 +26,4 @@ SPDX-License-Identifier: Apache-2.0 - [ ] I added or updated tests where appropriate. - [ ] I updated documentation where appropriate. - [ ] My commits use a correctly configured git identity (real name and email). +- [ ] All my commits are signed off (`git commit -s`) per the [DCO](../CONTRIBUTING.md#developer-certificate-of-origin-dco). diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6868561 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + +version: 2 +updates: + # Python dependencies — root project plus each publishable package. + - package-ecosystem: "pip" + directories: + - "/" + - "/packages/runtime" + - "/packages/connectors/*" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + groups: + python-dependencies: + patterns: ["*"] + + # GitHub Actions used by the workflows. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + groups: + github-actions: + patterns: ["*"] diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml new file mode 100644 index 0000000..33b242c --- /dev/null +++ b/.github/workflows/dco.yml @@ -0,0 +1,64 @@ +## +## SPDX-FileCopyrightText: 2026 AOT Technologies +## SPDX-License-Identifier: Apache-2.0 +## + + +name: DCO + +on: + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + dco: + name: DCO sign-off check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Verify every commit carries a DCO sign-off + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + + commits="$(git rev-list --no-merges "${BASE_SHA}..${HEAD_SHA}")" + if [ -z "${commits}" ]; then + echo "No non-merge commits to check." + exit 0 + fi + + status=0 + for sha in ${commits}; do + subject="$(git show -s --format='%s' "${sha}")" + author="$(git show -s --format='%an <%ae>' "${sha}")" + if git show -s --format='%B' "${sha}" \ + | grep -iqF "Signed-off-by: ${author}"; then + echo "PASS ${sha:0:12} ${subject}" + else + echo "FAIL ${sha:0:12} ${subject}" + echo " missing trailer: Signed-off-by: ${author}" + status=1 + fi + done + + if [ "${status}" -ne 0 ]; then + echo "" + echo "One or more commits are missing a Developer Certificate of Origin sign-off." + echo "See https://developercertificate.org/ and CONTRIBUTING.md." + echo "" + echo "Sign new commits with: git commit -s" + echo "Fix existing commits: git rebase --signoff ${BASE_SHA} && git push --force-with-lease" + exit 1 + fi + + echo "" + echo "All commits are signed off." diff --git a/.github/workflows/security-pr.yml b/.github/workflows/security-pr.yml index a3ff682..040571d 100644 --- a/.github/workflows/security-pr.yml +++ b/.github/workflows/security-pr.yml @@ -58,7 +58,7 @@ jobs: with: python-version: "3.11" - # Connector packages declare node-wire-runtime>=0.1.0 as a PyPI-style dep; install + # Connector packages declare node-wire-runtime>=1.0.0 as a PyPI-style dep; install # packages/runtime from the repo so pip resolves it without requiring a published wheel. - name: Install package and audit tool run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1a2cd..9c15ff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,25 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.0] - 2026-06-27 + +First stable release. The public API is now **frozen under Semantic Versioning** — +see [docs/versioning.md](docs/versioning.md) for the stability and deprecation +policy and [docs/public-api.md](docs/public-api.md) for the supported surface. + +### Added + +- Versioning, stability, and deprecation policy (`docs/versioning.md`). +- Public API reference enumerating the frozen surface (`docs/public-api.md`). +- `node_wire_runtime.__version__`. +- DCO sign-off enforcement, Dependabot, and `SUPPORT` / `ROADMAP` / `GOVERNANCE` docs. +- Test-coverage gate (`fail_under`). + +### Changed + +- Promoted from Beta to **Production/Stable**; all nine packages versioned `1.0.0`. +- Connectors now require `node-wire-runtime>=1.0.0`. + ## [0.1.0] - 2026-06-26 ### Added @@ -28,4 +47,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dependency lockfile upgraded to resolve known CVEs in transitive packages. - Packaging, publish workflow, and security scanning aligned on the nine-package surface. +[1.0.0]: https://github.com/AOT-Technologies/node-wire/releases/tag/v1.0.0 [0.1.0]: https://github.com/AOT-Technologies/node-wire/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 40778a3..fd28ceb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,9 @@ SPDX-License-Identifier: Apache-2.0 Thanks for your interest in contributing! This guide covers how to set up a development environment, the quality checks we enforce, and the conventions for submitting changes. By contributing you agree that your contributions are -licensed under the project's [Apache License 2.0](LICENSE). +licensed under the project's [Apache License 2.0](LICENSE) and that you sign off +each commit under the +[Developer Certificate of Origin](#developer-certificate-of-origin-dco). Please also read our [Code of Conduct](CODE_OF_CONDUCT.md). @@ -85,11 +87,75 @@ pre-commit and CI checks will fail if a file is missing its header. > default). Misconfigured identities get carried into the project history via > squash-merge co-author trailers and are difficult to remove later. +## Developer Certificate of Origin (DCO) + +This project uses the [Developer Certificate of Origin](https://developercertificate.org/) +(DCO) instead of a CLA. There is no agreement to sign and no paperwork — you +simply add a `Signed-off-by` trailer to **every commit**, certifying that you +have the right to submit it under the project's license. + +Git adds the trailer automatically when you commit with `-s`: + +```bash +git commit -s -m "Your commit message" +``` + +This appends a line matching your configured git identity (so set it correctly +first, per the section above): + +``` +Signed-off-by: Your Name +``` + +CI (`.github/workflows/dco.yml`) verifies that every commit in a pull request +carries a sign-off matching its author. If you forget, add sign-offs to your +branch's commits and force-push: + +```bash +git rebase --signoff main +git push --force-with-lease +``` + +By signing off, you certify the following: + +> Developer Certificate of Origin +> Version 1.1 +> +> Copyright (C) 2004, 2006 The Linux Foundation and its contributors. +> +> Everyone is permitted to copy and distribute verbatim copies of this license +> document, but changing it is not allowed. +> +> Developer's Certificate of Origin 1.1 +> +> By making a contribution to this project, I certify that: +> +> (a) The contribution was created in whole or in part by me and I have the +> right to submit it under the open source license indicated in the file; or +> +> (b) The contribution is based upon previous work that, to the best of my +> knowledge, is covered under an appropriate open source license and I have +> the right under that license to submit that work with modifications, +> whether created in whole or in part by me, under the same open source +> license (unless I am permitted to submit under a different license), as +> indicated in the file; or +> +> (c) The contribution was provided directly to me by some other person who +> certified (a), (b) or (c) and I have not modified it. +> +> (d) I understand and agree that this project and the contribution are public +> and that a record of the contribution (including all personal information +> I submit with it, including my sign-off) is maintained indefinitely and +> may be redistributed consistent with this project or the open source +> license(s) involved. + ## Submitting Changes 1. Fork the repository and create a feature branch from `main` (e.g. `feature/short-description` or `fix/short-description`). 2. Make your change, including tests and documentation updates where relevant. + Sign off every commit with `git commit -s` (see + [DCO](#developer-certificate-of-origin-dco)). 3. Ensure all quality checks above pass locally. 4. Open a pull request against `main` with a clear description of the change and the motivation behind it. Link any related issue. diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..16a7c89 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,42 @@ + + +# Governance + +Node Wire is an open-source project stewarded by **AOT Technologies**. This +document describes how decisions are made and how the project is maintained. + +## Roles + +- **Maintainers** — review and merge contributions, triage issues, cut releases, + and set technical direction. +- **Contributors** — anyone who submits issues or pull requests under the + [Contributing guide](CONTRIBUTING.md) and + [DCO](CONTRIBUTING.md#developer-certificate-of-origin-dco). + +## Decision making + +- Routine changes are approved by maintainer review on a pull request (at least + one maintainer approval). +- Significant or breaking changes are discussed in an issue or Discussion before + implementation and require maintainer consensus. As project steward, AOT + Technologies is the final decision maker where consensus cannot be reached. +- Public API changes follow the [versioning & stability policy](docs/versioning.md). + +## Releases + +Releases are cut by maintainers following the +[release-readiness checklist](docs/release-readiness-checklist.md) and published +via the automated workflow. See [CHANGELOG.md](CHANGELOG.md). + +## Becoming a maintainer + +Sustained, high-quality contributions may lead to an invitation to become a +maintainer, at the discretion of the existing maintainers. + +## Code of Conduct + +All participation is governed by the [Code of Conduct](CODE_OF_CONDUCT.md). diff --git a/README.md b/README.md index cb482ef..e9799ce 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ SPDX-License-Identifier: Apache-2.0 # Node Wire +[![CI](https://github.com/AOT-Technologies/node-wire/actions/workflows/pytest.yml/badge.svg)](https://github.com/AOT-Technologies/node-wire/actions/workflows/pytest.yml) +[![CodeQL](https://github.com/AOT-Technologies/node-wire/actions/workflows/codeql.yml/badge.svg)](https://github.com/AOT-Technologies/node-wire/actions/workflows/codeql.yml) +[![PyPI](https://img.shields.io/pypi/v/node-wire.svg)](https://pypi.org/project/node-wire/) +[![Python](https://img.shields.io/pypi/pyversions/node-wire.svg)](https://pypi.org/project/node-wire/) +[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) + Node Wire is a three-layer Python platform that runs connector adapters (Google Drive, SMTP, Stripe, FHIR, etc.) and exposes them over REST, gRPC, or MCP. It provides a consistent execution contract with built-in validation, resilience, and telemetry. ## Prerequisites @@ -115,20 +121,20 @@ All MCP server images are built from the repository root using the automation sc To tag with a specific version (defaults to the version in `pyproject.toml`): ```bash -./scripts/build-mcp-images.sh --version 0.1.0 +./scripts/build-mcp-images.sh --version 1.0.0 ``` This produces images tagged as both `latest` and the version string: | Image name | Tags | |---|---| -| `nw-google-drive` | `nw-google-drive:latest`, `nw-google-drive:0.1.0` | -| `nw-smartonfhir-epic` | `nw-smartonfhir-epic:latest`, `nw-smartonfhir-epic:0.1.0` | -| `nw-smartonfhir-cerner` | `nw-smartonfhir-cerner:latest`, `nw-smartonfhir-cerner:0.1.0` | -| `nw-smtp` | `nw-smtp:latest`, `nw-smtp:0.1.0` | -| `nw-stripe` | `nw-stripe:latest`, `nw-stripe:0.1.0` | -| `nw-salesforce` | `nw-salesforce:latest`, `nw-salesforce:0.1.0` | -| `nw-slack` | `nw-slack:latest`, `nw-slack:0.1.0` | +| `nw-google-drive` | `nw-google-drive:latest`, `nw-google-drive:1.0.0` | +| `nw-smartonfhir-epic` | `nw-smartonfhir-epic:latest`, `nw-smartonfhir-epic:1.0.0` | +| `nw-smartonfhir-cerner` | `nw-smartonfhir-cerner:latest`, `nw-smartonfhir-cerner:1.0.0` | +| `nw-smtp` | `nw-smtp:latest`, `nw-smtp:1.0.0` | +| `nw-stripe` | `nw-stripe:latest`, `nw-stripe:1.0.0` | +| `nw-salesforce` | `nw-salesforce:latest`, `nw-salesforce:1.0.0` | +| `nw-slack` | `nw-slack:latest`, `nw-slack:1.0.0` | ### Build one image manually diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..a852fac --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,34 @@ + + +# Roadmap + +This roadmap is **indicative, not a commitment** — priorities may shift. Track +detailed work in [GitHub Issues](https://github.com/AOT-Technologies/node-wire/issues) +and milestones. + +## Now (1.0.x) + +- Stabilize the 1.0 public API and grow test coverage to the release gate. +- Harden the connector test matrix (mocked integration tests in PR CI). + +## Next + +- Additional connectors and a connector-authoring guide. +- Expanded API-reference documentation and a published docs site. +- Cross-platform (macOS / Windows) test coverage in CI. + +## Later / under consideration + +- Performance and load benchmarks with published baselines. +- Additional secret-provider and auth backends. + +## Out of scope + +- Breaking changes to the public API within `1.x` — reserved for `2.0` + (see the [versioning policy](docs/versioning.md)). + +*Have a request? Open a feature-request issue or start a Discussion.* diff --git a/SECURITY.md b/SECURITY.md index d816a5a..beab5c6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,13 +12,13 @@ vulnerability responsibly. ## Supported Versions -Node Wire is pre-1.0 and under active development. Security fixes are applied to -the latest released minor version and the `main` branch. +Node Wire follows [Semantic Versioning](https://semver.org/). Security fixes are +applied to the latest 1.x release and the `main` branch. | Version | Supported | | ------- | ------------------ | -| 0.1.x | :white_check_mark: | -| < 0.1 | :x: | +| 1.0.x | :white_check_mark: | +| < 1.0 | :x: | ## Reporting a Vulnerability diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..069073e --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,35 @@ + + +# Support + +Thanks for using Node Wire! Here's how to get help. + +## Questions & discussion + +- **GitHub Discussions** — for usage questions, ideas, and general discussion. + *(Enable Discussions in the repo settings, or use Issues until then.)* +- **Documentation** — start with the [docs/](docs/) directory and the [README](README.md). + +## Bugs & feature requests + +- Open a [GitHub Issue](https://github.com/AOT-Technologies/node-wire/issues) + using the bug-report or feature-request template. +- Search existing issues first to avoid duplicates. + +## Security issues + +- **Do not** open a public issue. Follow the [Security Policy](SECURITY.md) + (GitHub private advisory or security@aot-technologies.com). + +## Commercial support + +For commercial support inquiries, contact **opensource@aot-technologies.com**. + +## Supported versions + +See the support matrix in [SECURITY.md](SECURITY.md) and the +[versioning policy](docs/versioning.md). diff --git a/docs/connectors.md b/docs/connectors.md index a0f55e8..282cb27 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -29,7 +29,7 @@ Each connector is a **top-level package** under `src/` (e.g. `node_wire_fhir_epi | `registration.py` | Optional: registers connector-specific exceptions with `ErrorMapper`. | | `exceptions.py` | Optional: custom exception types. | -At startup, call **`node_wire_runtime.connector_registry.auto_register()`**: it loads entry points in group `node_wire.connectors`, imports each connector's `logic` module (triggering `BaseConnector.__init_subclass__` and `_CONNECTOR_REGISTRY`), then imports optional `registration.py` for `ErrorMapper` side effects. +At startup, call **`node_wire_runtime.connector_registry.auto_register()`**: it loads entry points in group `node_wire.connectors`, imports each connector's `logic` module (triggering `BaseConnector.__init_subclass__` and registration via `get_connector_registry()`), then imports optional `registration.py` for `ErrorMapper` side effects. --- @@ -295,7 +295,7 @@ connectors: ### Step 5 — Auto-registration (nothing extra needed) -`BaseConnector.__init_subclass__` adds your class to `_CONNECTOR_REGISTRY[connector_id]` as soon as `logic.py` is imported. **`node_wire_runtime.connector_registry.auto_register()`** performs those imports at startup. **No manual factory branch is required.** +`BaseConnector.__init_subclass__` registers your class (exposed via `get_connector_registry()`) as soon as `logic.py` is imported. **`node_wire_runtime.connector_registry.auto_register()`** performs those imports at startup. **No manual factory branch is required.** --- @@ -520,7 +520,7 @@ connectors: | Method | Description | |--------|-------------| -| `load()` | Reads YAML, instantiates all enabled connectors from `_CONNECTOR_REGISTRY`. | +| `load()` | Reads YAML, instantiates all enabled connectors from the connector registry (`get_connector_registry()`). | | `get_for_protocol(id, protocol, action=None)` | Returns connector if enabled and exposed for that protocol; `None` otherwise. | | `list_for_protocol(protocol)` | All connectors exposed for a given protocol. | diff --git a/docs/public-api.md b/docs/public-api.md new file mode 100644 index 0000000..7354ebd --- /dev/null +++ b/docs/public-api.md @@ -0,0 +1,73 @@ + + +# Public API Reference (1.x) + +This page enumerates the **stable public API** covered by the +[versioning & stability policy](versioning.md). Anything not listed here — and +anything named with a leading underscore — is internal and may change without a +major version bump. + +## `node_wire_runtime` + +Stable top-level exports (`node_wire_runtime.__all__`): + +### Connector authoring +- `BaseConnector` — base class for connectors. +- `get_connector_registry()` — returns a copy of the connector-id → class registry. +- `nw_action`, `sdk_action` — action decorators. +- `SdkActionSpec`, `default_build_kwargs`, `execute_spec_in_thread`, `navigate_resource`. +- `NestedConnectorActionError`. + +### Responses & errors +- `ConnectorResponse` +- `ErrorCategory` +- `ErrorMapper` + +### Authentication +- `AuthProvider` (base), `NoAuthProvider`, `StaticTokenAuthProvider`, + `OAuth2AuthProvider`, `ServiceAccountAuthProvider`. +- `CallerIdentity`, `build_caller_identity`. + +### Policy +- `PolicyHook`, `PolicyDenied`. + +### Secrets +- `SecretProvider` (base), `EnvSecretProvider`, `SecretNotFoundError`, `SecretProviderError`. + +### Streaming +- `StreamSignal`, `stream_completion_log`, `resolve_stream_buffer_ms`, `BufferedStreamIterator`. + +### Version +- `__version__` + +## Connector contract (extensibility API) + +Connector authors depend on these stable modules: + +- `node_wire_runtime.base_connector` — `BaseConnector`, action decorators. +- `node_wire_runtime.mcp_contract` — MCP tool contract flags. +- `node_wire_runtime.auth.base` — `AuthProvider` interface. +- `node_wire_runtime.secrets.base` — `SecretProvider` interface. + +Connectors register via the `node_wire.connectors` entry-point group. + +## Wire contracts + +- **REST** — routes and request/response schemas served by the API binding + (Swagger UI at `/docs`). +- **gRPC** — the `Connector` service defined by the committed protobuf contract. +- **MCP** — tool manifests exposed by the MCP servers (`nw-*`). + +## Configuration + +- `connectors.yaml` schema — see [configuration.md](configuration.md). +- `NW_*` environment variables — see [configuration.md](configuration.md). + +## Console scripts + +`node-wire`, `nw-google-drive`, `nw-smartonfhir-epic`, `nw-smartonfhir-cerner`, +`nw-smtp`, `nw-stripe`, `nw-salesforce`, `nw-slack`. diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 0000000..ad912c4 --- /dev/null +++ b/docs/versioning.md @@ -0,0 +1,69 @@ + + +# Versioning, Stability & Deprecation Policy + +Node Wire follows [Semantic Versioning 2.0.0](https://semver.org/). As of +**1.0.0**, the public API is stable and covered by the guarantees below. + +## Semantic Versioning + +Given a version `MAJOR.MINOR.PATCH`: + +- **MAJOR** — backward-incompatible changes to the public API. +- **MINOR** — new, backward-compatible functionality (including new connectors). +- **PATCH** — backward-compatible bug fixes. + +Within a major version (`1.x`) we do **not** make breaking changes to the public +API. Breaking changes are reserved for the next major (`2.0.0`). + +## What is "public" (covered by SemVer) + +The stability guarantee covers the surface enumerated in the +[Public API Reference](public-api.md): + +- Symbols exported from `node_wire_runtime` (its `__all__`). +- The connector authoring contract: `BaseConnector`, `@nw_action` / `@sdk_action`, + `ConnectorResponse`, error categories, and the auth/secret provider base classes. +- The connector entry-point group `node_wire.connectors`. +- The REST, gRPC, and MCP wire contracts (request/response shapes and routes). +- Documented configuration: the `connectors.yaml` schema and `NW_*` environment variables. +- Console entry points (`node-wire`, `nw-*`). + +## What is NOT public (may change in any release) + +- Any module, class, function, or attribute whose name begins with `_`. +- Anything in internal modules not re-exported from a package's `__init__`. +- Generated code (`*_pb2.py`, `*_pb2_grpc.py`) beyond the documented gRPC service contract. +- Test suites, the `playground/` package, build/packaging scripts, and CI config. +- Exact log message text, internal telemetry attribute names, and temp-file layouts. + +If you depend on something not listed as public, pin an exact version and expect +it may change without a major bump. + +## Deprecation policy + +Public API is removed only across a major version. Before removal: + +1. The API is marked **deprecated** in at least one **minor** release beforehand. +2. Deprecated behavior keeps working for the remainder of the current major series. +3. Where practical, use raises a `DeprecationWarning` and/or is flagged in the + connector manifest (actions support a `deprecated` flag) and in the changelog. +4. Removal happens only in the next `MAJOR` release, with migration notes in the changelog. + +Connector *actions* and MCP tool arguments can be deprecated individually via the +manifest `deprecated` flag; deprecated MCP arguments follow a phased removal. + +## Supported versions + +Security and bug fixes target the latest `1.x` release and `main`. See +[SECURITY.md](../SECURITY.md) for the support matrix and reporting process. + +## Release process + +Each release is tagged `vMAJOR.MINOR.PATCH`, published to PyPI via Trusted +Publisher (OIDC) with Sigstore attestations, and recorded in +[CHANGELOG.md](../CHANGELOG.md). diff --git a/packages/connectors/fhir_cerner/pyproject.toml b/packages/connectors/fhir_cerner/pyproject.toml index 126a01b..a9579e6 100644 --- a/packages/connectors/fhir_cerner/pyproject.toml +++ b/packages/connectors/fhir_cerner/pyproject.toml @@ -4,14 +4,14 @@ [project] name = "node-wire-fhir-cerner" -version = "0.1.0" +version = "1.0.0" description = "Node Wire connector — Cerner FHIR R4 (read/search patients and encounters)" requires-python = ">=3.11" license = "Apache-2.0" authors = [{ name = "AOT Technologies", email = "opensource@aot-technologies.com" }] dependencies = [ - "node-wire-runtime>=0.1.0", + "node-wire-runtime>=1.0.0", "httpx[http2]>=0.27.0,<0.28.0", "PyJWT[crypto]>=2.8.0", ] diff --git a/packages/connectors/fhir_epic/pyproject.toml b/packages/connectors/fhir_epic/pyproject.toml index 2992a6b..8f3c8bd 100644 --- a/packages/connectors/fhir_epic/pyproject.toml +++ b/packages/connectors/fhir_epic/pyproject.toml @@ -4,14 +4,14 @@ [project] name = "node-wire-fhir-epic" -version = "0.1.0" +version = "1.0.0" description = "Node Wire connector — Epic FHIR R4 (read/search patients and encounters)" requires-python = ">=3.11" license = "Apache-2.0" authors = [{ name = "AOT Technologies", email = "opensource@aot-technologies.com" }] dependencies = [ - "node-wire-runtime>=0.1.0", + "node-wire-runtime>=1.0.0", "httpx[http2]>=0.27.0,<0.28.0", "PyJWT[crypto]>=2.8.0", ] diff --git a/packages/connectors/google_drive/pyproject.toml b/packages/connectors/google_drive/pyproject.toml index 550d613..075b9f8 100644 --- a/packages/connectors/google_drive/pyproject.toml +++ b/packages/connectors/google_drive/pyproject.toml @@ -4,14 +4,14 @@ [project] name = "node-wire-google-drive" -version = "0.1.0" +version = "1.0.0" description = "Node Wire connector — Google Drive API v3 (files and permissions)" requires-python = ">=3.11" license = "Apache-2.0" authors = [{ name = "AOT Technologies", email = "opensource@aot-technologies.com" }] dependencies = [ - "node-wire-runtime>=0.1.0", + "node-wire-runtime>=1.0.0", "google-auth>=2.0.0", "google-api-python-client>=2.100.0", ] diff --git a/packages/connectors/http_generic/pyproject.toml b/packages/connectors/http_generic/pyproject.toml index 29c691c..c4e3ad6 100644 --- a/packages/connectors/http_generic/pyproject.toml +++ b/packages/connectors/http_generic/pyproject.toml @@ -4,14 +4,14 @@ [project] name = "node-wire-http-generic" -version = "0.1.0" +version = "1.0.0" description = "Node Wire connector — generic HTTP REST client (GET/POST/PUT/DELETE/PATCH)" requires-python = ">=3.11" license = "Apache-2.0" authors = [{ name = "AOT Technologies", email = "opensource@aot-technologies.com" }] dependencies = [ - "node-wire-runtime>=0.1.0", + "node-wire-runtime>=1.0.0", "httpx[http2]>=0.27.0,<0.28.0", ] diff --git a/packages/connectors/salesforce/pyproject.toml b/packages/connectors/salesforce/pyproject.toml index 1c107e1..8af24ad 100644 --- a/packages/connectors/salesforce/pyproject.toml +++ b/packages/connectors/salesforce/pyproject.toml @@ -4,14 +4,14 @@ [project] name = "node-wire-salesforce" -version = "0.1.0" +version = "1.0.0" description = "Node Wire connector — Salesforce CRM" requires-python = ">=3.11" license = "Apache-2.0" authors = [{ name = "AOT Technologies", email = "opensource@aot-technologies.com" }] dependencies = [ - "node-wire-runtime>=0.1.0", + "node-wire-runtime>=1.0.0", "httpx[http2]>=0.27.0,<0.28.0", ] diff --git a/packages/connectors/slack/pyproject.toml b/packages/connectors/slack/pyproject.toml index 7b494e3..255a1bb 100644 --- a/packages/connectors/slack/pyproject.toml +++ b/packages/connectors/slack/pyproject.toml @@ -4,14 +4,14 @@ [project] name = "node-wire-slack" -version = "0.1.0" +version = "1.0.0" description = "Node Wire connector — Slack API (messaging and file uploads)" requires-python = ">=3.11" license = "Apache-2.0" authors = [{ name = "AOT Technologies", email = "opensource@aot-technologies.com" }] dependencies = [ - "node-wire-runtime>=0.1.0", + "node-wire-runtime>=1.0.0", "httpx[http2]>=0.27.0,<0.28.0", ] diff --git a/packages/connectors/smtp/pyproject.toml b/packages/connectors/smtp/pyproject.toml index b3a539c..8a57669 100644 --- a/packages/connectors/smtp/pyproject.toml +++ b/packages/connectors/smtp/pyproject.toml @@ -4,14 +4,14 @@ [project] name = "node-wire-smtp" -version = "0.1.0" +version = "1.0.0" description = "Node Wire connector — SMTP email sending (async)" requires-python = ">=3.11" license = "Apache-2.0" authors = [{ name = "AOT Technologies", email = "opensource@aot-technologies.com" }] dependencies = [ - "node-wire-runtime>=0.1.0", + "node-wire-runtime>=1.0.0", "aiosmtplib>=3.0.1", "email-validator>=2.0.0", ] diff --git a/packages/connectors/stripe/pyproject.toml b/packages/connectors/stripe/pyproject.toml index 3ec298f..fc9bc04 100644 --- a/packages/connectors/stripe/pyproject.toml +++ b/packages/connectors/stripe/pyproject.toml @@ -4,14 +4,14 @@ [project] name = "node-wire-stripe" -version = "0.1.0" +version = "1.0.0" description = "Node Wire connector — Stripe payments" requires-python = ">=3.11" license = "Apache-2.0" authors = [{ name = "AOT Technologies", email = "opensource@aot-technologies.com" }] dependencies = [ - "node-wire-runtime>=0.1.0", + "node-wire-runtime>=1.0.0", "stripe>=10.0.0", ] diff --git a/packages/runtime/pyproject.toml b/packages/runtime/pyproject.toml index 110ada8..bad8273 100644 --- a/packages/runtime/pyproject.toml +++ b/packages/runtime/pyproject.toml @@ -4,7 +4,7 @@ [project] name = "node-wire-runtime" -version = "0.1.0" +version = "1.0.0" description = "Node Wire runtime — connector framework, resilience, observability, and pluggable secrets" requires-python = ">=3.11" license = "Apache-2.0" diff --git a/pyproject.toml b/pyproject.toml index 88630a3..d43584a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 [project] name = "node-wire" -version = "0.1.0" +version = "1.0.0" description = "Node Wire — runtime, connectors, and bindings" license = "Apache-2.0" requires-python = ">=3.11" @@ -14,7 +14,7 @@ authors = [ readme = "README.md" keywords = ["connectors", "mcp", "rest", "grpc", "integration", "runtime"] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", @@ -132,6 +132,10 @@ source = ["src"] branch = false [tool.coverage.report] +# 1.0 release gate: the test run fails if total coverage drops below this floor. +# Current coverage is well below 80% — raise coverage before tagging v1.0.0, or +# temporarily lower this value during the ramp (do not remove the gate). +fail_under = 80 omit = [ "*/__pycache__/*", "*/node_wire.egg-info/*", diff --git a/src/bindings/factory.py b/src/bindings/factory.py index 214425d..fc63426 100644 --- a/src/bindings/factory.py +++ b/src/bindings/factory.py @@ -13,8 +13,7 @@ import yaml -from node_wire_runtime import BaseConnector, SecretProvider -from node_wire_runtime.base_connector import _CONNECTOR_REGISTRY +from node_wire_runtime import BaseConnector, SecretProvider, get_connector_registry from node_wire_runtime.policy import PolicyHook from node_wire_runtime.policies.mcp_scope_policy import ( DEFAULT_SCOPE_MODE_DENY, @@ -190,7 +189,7 @@ def load(self) -> None: ) continue - if connector_id not in _CONNECTOR_REGISTRY: + if connector_id not in get_connector_registry(): logger.warning( "Connector enabled in configuration but not registered; skipping instantiation", extra={ @@ -281,7 +280,7 @@ async def get_client_credentials(self): # type: ignore[override] return NoAuthProvider() def _instantiate(self, connector_id: str) -> "BaseConnector | None": - connector_cls = _CONNECTOR_REGISTRY.get(connector_id) + connector_cls = get_connector_registry().get(connector_id) if connector_cls is not None: cfg = self._configs[connector_id] auth_provider = self._build_auth_provider(connector_id, cfg.raw) diff --git a/src/node_wire_runtime/__init__.py b/src/node_wire_runtime/__init__.py index 1beaa6e..58c09f0 100644 --- a/src/node_wire_runtime/__init__.py +++ b/src/node_wire_runtime/__init__.py @@ -17,9 +17,9 @@ from .base_connector import ( BaseConnector, NestedConnectorActionError, + get_connector_registry, nw_action, sdk_action, - _CONNECTOR_REGISTRY, ) from .sdk_action_spec import ( SdkActionSpec, @@ -34,6 +34,8 @@ BufferedStreamIterator, ) +__version__ = "1.0.0" + __all__ = [ "ConnectorResponse", "ErrorCategory", @@ -55,7 +57,7 @@ "NestedConnectorActionError", "sdk_action", "nw_action", - "_CONNECTOR_REGISTRY", + "get_connector_registry", "SdkActionSpec", "default_build_kwargs", "execute_spec_in_thread", diff --git a/src/node_wire_runtime/base_connector.py b/src/node_wire_runtime/base_connector.py index d57c064..eff46b9 100644 --- a/src/node_wire_runtime/base_connector.py +++ b/src/node_wire_runtime/base_connector.py @@ -77,6 +77,11 @@ def _merge_nested_failure_details(nested: ConnectorResponse) -> Any: _CONNECTOR_REGISTRY: Dict[str, Type["BaseConnector"]] = {} +def get_connector_registry() -> Dict[str, Type["BaseConnector"]]: + """Return a copy of the global connector-id -> connector-class registry.""" + return dict(_CONNECTOR_REGISTRY) + + def _make_spec_handler( action_name: str, input_model: Any, @@ -588,8 +593,8 @@ async def _do_execute(*, trace_id: str) -> Any: @classmethod def get_registry(cls) -> Dict[str, Type[BaseConnector]]: - """Public access to the global connector registry.""" - return dict(_CONNECTOR_REGISTRY) + """Backward-compatible alias for :func:`get_connector_registry`.""" + return get_connector_registry() @classmethod def sdk_action_metas(cls) -> Dict[str, NwActionMeta]: diff --git a/src/node_wire_runtime/connector_registry.py b/src/node_wire_runtime/connector_registry.py index 7012f56..29db662 100644 --- a/src/node_wire_runtime/connector_registry.py +++ b/src/node_wire_runtime/connector_registry.py @@ -93,7 +93,7 @@ def auto_register() -> List[str]: For each entry point: 1. Load the ``logic`` module — triggers ``BaseConnector.__init_subclass__``, - which populates ``_CONNECTOR_REGISTRY``. + which populates the registry exposed via ``get_connector_registry()``. 2. Attempt to load a sibling ``registration`` module (optional) for ``ErrorMapper`` registrations and other import-time side effects. diff --git a/tests/test_connectors_io.py b/tests/test_connectors_io.py index 277006f..93b49a8 100644 --- a/tests/test_connectors_io.py +++ b/tests/test_connectors_io.py @@ -324,3 +324,111 @@ async def _run() -> None: metadata=None, idempotency_key=ANY, ) + + +# --------------------------------------------------------------------------- +# SSRF resolver edge cases and request exception logging +# --------------------------------------------------------------------------- + + +def test_assert_safe_destination_missing_host() -> None: + from node_wire_http_generic.logic import SsrfBlockedError, _assert_safe_destination + + async def _run() -> None: + with pytest.raises(SsrfBlockedError, match="host is missing"): + await _assert_safe_destination("http:///path") + + asyncio.run(_run()) + + +def test_assert_safe_destination_gaierror() -> None: + from node_wire_http_generic.logic import SsrfBlockedError, _assert_safe_destination + + async def _run() -> None: + loop = asyncio.get_event_loop() + + async def _failing_resolver(host, port, *args, **kwargs): + raise socket.gaierror("Name or service not known") + + with patch.object(loop, "getaddrinfo", new=_failing_resolver): + with pytest.raises(SsrfBlockedError, match="could not be resolved"): + await _assert_safe_destination("http://nonexistent.example.invalid/x") + + asyncio.run(_run()) + + +def test_assert_safe_destination_empty_getaddrinfo() -> None: + from node_wire_http_generic.logic import SsrfBlockedError, _assert_safe_destination + + async def _run() -> None: + loop = asyncio.get_event_loop() + + async def _empty_resolver(host, port, *args, **kwargs): + return [] + + with patch.object(loop, "getaddrinfo", new=_empty_resolver): + with pytest.raises(SsrfBlockedError, match="could not be resolved"): + await _assert_safe_destination("http://empty-dns.example.com/x") + + asyncio.run(_run()) + + +def test_assert_safe_destination_unparsable_ip() -> None: + from node_wire_http_generic.logic import SsrfBlockedError, _assert_safe_destination + + async def _run() -> None: + loop = asyncio.get_event_loop() + + async def _bad_ip_resolver(host, port, *args, **kwargs): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("not-an-ip", port))] + + with patch.object(loop, "getaddrinfo", new=_bad_ip_resolver): + with pytest.raises(SsrfBlockedError, match="unparsable address"): + await _assert_safe_destination("http://bad-ip.example.com/x") + + asyncio.run(_run()) + + +def test_sanitize_url_for_log_ipv6_and_invalid() -> None: + from unittest.mock import patch + + from node_wire_http_generic.logic import _sanitize_url_for_log + + assert _sanitize_url_for_log("https://[2001:db8::1]:8443/path?q=1") == "https://[2001:db8::1]:8443/path" + with patch("node_wire_http_generic.logic.urlsplit", side_effect=ValueError("bad url")): + assert _sanitize_url_for_log("not-a-url") == "" + + +def test_http_request_exception_before_response_logged() -> None: + class _FailingAsyncClient: + async def __aenter__(self) -> "_FailingAsyncClient": + return self + + async def __aexit__(self, *args: object) -> None: + return None + + async def request(self, **kwargs: object) -> MagicMock: + raise httpx.ConnectError("connection refused") + + async def _run() -> None: + with ( + patch( + "node_wire_http_generic.logic.httpx.AsyncClient", + return_value=_FailingAsyncClient(), + ), + patch( + "node_wire_http_generic.logic._assert_safe_destination", + new=AsyncMock(return_value=None), + ), + patch("node_wire_http_generic.logic.logger.error") as mocked_error, + ): + c = HttpGenericConnector() + inp = HttpRequestInput(url="https://example.com/path", method="GET") + with pytest.raises(httpx.ConnectError): + await c.internal_execute(inp, trace_id="t-err") + + mocked_error.assert_called_once() + extra = mocked_error.call_args.kwargs.get("extra") or {} + assert extra.get("error_type") == "ConnectError" + + asyncio.run(_run()) diff --git a/tests/test_fhir_cerner.py b/tests/test_fhir_cerner.py index 9880066..4cf096c 100644 --- a/tests/test_fhir_cerner.py +++ b/tests/test_fhir_cerner.py @@ -652,3 +652,240 @@ async def post_side_effect(*args: object, **kwargs: object) -> httpx.Response | with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=err_resp): with pytest.raises(ValueError, match="Cerner Error:.*Cerner rejected payload"): await c.internal_execute(params, trace_id="test-trace") + + +# --------------------------------------------------------------------------- +# Additional coverage: error paths and validation branches +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_fhir_cerner_read_patient_empty_bundle_raises(): + c = _connector() + from node_wire_fhir_cerner.schema import FhirCernerPatientReadInput + + params = FhirCernerPatientReadInput(action="read_patient", name="Nobody") + + empty_bundle = MagicMock() + empty_bundle.status_code = 200 + empty_bundle.json.return_value = {"resourceType": "Bundle", "total": 0, "entry": []} + + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=empty_bundle): + with pytest.raises(ValueError, match="No patients found"): + await c.internal_execute(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_fhir_cerner_read_patient_http_error(): + c = _connector() + from node_wire_fhir_cerner.schema import FhirCernerPatientReadInput + + params = FhirCernerPatientReadInput(action="read_patient", resource_id="999") + + error_resp = MagicMock() + error_resp.raise_for_status.side_effect = Exception("server error") + + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=error_resp): + with pytest.raises(Exception, match="server error"): + await c.internal_execute(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_fhir_cerner_search_patients_empty_resource_ids_raises(): + c = _connector() + from node_wire_fhir_cerner.schema import FhirCernerPatientSearchInput + + params = FhirCernerPatientSearchInput(action="search_patients", resource_ids=[" "]) + + with pytest.raises(ValueError, match="resource_ids list is empty"): + await c.internal_execute(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_fhir_cerner_search_patients_name_search_http_status_error(): + c = _connector() + from node_wire_fhir_cerner.schema import FhirCernerPatientSearchInput + + params = FhirCernerPatientSearchInput(action="search_patients", family_name="Smith") + + request = httpx.Request("GET", "https://fhir.cerner.com/Patient") + response = httpx.Response(403, request=request, text="Forbidden") + http_error = httpx.HTTPStatusError("Forbidden", request=request, response=response) + + error_resp = MagicMock() + error_resp.raise_for_status.side_effect = http_error + + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=error_resp): + with pytest.raises(httpx.HTTPStatusError): + await c.internal_execute(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_fhir_cerner_search_patients_name_search_generic_error(): + c = _connector() + from node_wire_fhir_cerner.schema import FhirCernerPatientSearchInput + + params = FhirCernerPatientSearchInput(action="search_patients", family_name="Smith") + + with patch( + "httpx.AsyncClient.get", + new_callable=AsyncMock, + side_effect=httpx.ConnectError("connection refused"), + ): + with pytest.raises(httpx.ConnectError): + await c.internal_execute(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_fhir_cerner_search_encounter_by_explicit_date(): + c = _connector() + from node_wire_fhir_cerner.schema import FhirCernerEncounterSearchInput + + params = FhirCernerEncounterSearchInput( + action="search_encounter", + patient_id="12345678", + status="finished", + date="2024-06-01", + ) + + enc_response = MagicMock() + enc_response.status_code = 200 + enc_response.json.return_value = { + "resourceType": "Bundle", + "total": 1, + "entry": [{"resource": {"resourceType": "Encounter", "id": "enc-date"}}], + } + + with patch( + "httpx.AsyncClient.get", new_callable=AsyncMock, return_value=enc_response + ) as mock_get: + result = await c.internal_execute(params, trace_id="test-trace") + + assert result.resources[0]["id"] == "enc-date" + sent_params = mock_get.call_args.kwargs.get("params") or mock_get.call_args[1].get("params", {}) + assert sent_params.get("date") == "2024-06-01" + + +@pytest.mark.asyncio +async def test_fhir_cerner_search_encounter_no_params_raises(): + c = _connector() + from node_wire_fhir_cerner.schema import FhirCernerEncounterSearchInput + + params = FhirCernerEncounterSearchInput(action="search_encounter") + + with pytest.raises(ValueError, match="Provide at least patient_id"): + await c.internal_execute(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_fhir_cerner_search_encounter_http_status_error(): + c = _connector() + from node_wire_fhir_cerner.schema import FhirCernerEncounterSearchInput + + params = FhirCernerEncounterSearchInput( + action="search_encounter", + search_params={"patient": "12345678"}, + ) + + request = httpx.Request("GET", "https://fhir.cerner.com/Encounter") + response = httpx.Response(403, request=request, text="Forbidden") + http_error = httpx.HTTPStatusError("Forbidden", request=request, response=response) + + error_resp = MagicMock() + error_resp.raise_for_status.side_effect = http_error + + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=error_resp): + with pytest.raises(httpx.HTTPStatusError): + await c.internal_execute(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_fhir_cerner_create_document_reference_with_text(): + c = _connector() + from node_wire_fhir_cerner.schema import FhirCernerDocumentReferenceCreateInput + + params = FhirCernerDocumentReferenceCreateInput( + action="create_document_reference", + status="current", + doc_status="final", + type={ + "coding": [ + { + "system": "urn:oid:4.5.6", + "code": "18100", + "display": "Note", + "userSelected": True, + } + ], + "text": "Note", + }, + subject="Patient/12724066", + text="plain note body", + attachment_title="Document", + author=[{"reference": "Practitioner/p1"}], + ) + + create_response = MagicMock() + create_response.status_code = 201 + create_response.headers = { + "Location": "https://fhir-myrecord.cerner.com/r4/tenant-id/DocumentReference/doc-text/_history/1" + } + create_response.content = b"" + create_response.text = "" + + with patch( + "httpx.AsyncClient.post", new_callable=AsyncMock, return_value=create_response + ) as mock_post: + result = await c.internal_execute(params, trace_id="test-trace") + + assert result.resource_id == "doc-text" + attachment = mock_post.call_args.kwargs["json"]["content"][0]["attachment"] + assert "data" in attachment + + +@pytest.mark.asyncio +async def test_fhir_cerner_create_document_reference_missing_author_raises(): + c = _connector() + from node_wire_fhir_cerner.schema import FhirCernerDocumentReferenceCreateInput + + params = FhirCernerDocumentReferenceCreateInput( + action="create_document_reference", + status="current", + doc_status="final", + type={ + "coding": [ + { + "system": "urn:oid:4.5.6", + "code": "18100", + "display": "Note", + "userSelected": True, + } + ], + "text": "Note", + }, + subject="Patient/12724066", + data="dGVzdA==", + attachment_title="Document", + ) + + with pytest.raises(ValueError, match="Cerner requires 'author'"): + await c.internal_execute(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_fhir_cerner_search_document_reference_generic_error(): + c = _connector() + from node_wire_fhir_cerner.schema import FhirCernerDocumentReferenceSearchInput + + params = FhirCernerDocumentReferenceSearchInput( + action="search_document_reference", + search_params={"patient": "12345678"}, + ) + + with patch( + "httpx.AsyncClient.get", + new_callable=AsyncMock, + side_effect=httpx.ConnectError("connection refused"), + ): + with pytest.raises(httpx.ConnectError): + await c.internal_execute(params, trace_id="test-trace") diff --git a/tests/test_fhir_epic.py b/tests/test_fhir_epic.py index 5727401..8710e33 100644 --- a/tests/test_fhir_epic.py +++ b/tests/test_fhir_epic.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest from node_wire_fhir_epic.logic import FhirEpicConnector @@ -458,3 +459,405 @@ async def test_fhir_epic_search_document_reference(): assert result.total == 1 assert result.resources[0]["id"] == "doc-789" + + +# --------------------------------------------------------------------------- +# search_encounter — explicit patient_id / status / date fields +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_fhir_epic_search_encounter_by_explicit_fields(): + c = _connector() + from node_wire_fhir_epic.schema import FhirEncounterSearchInput + + params = FhirEncounterSearchInput( + action="search_encounter", + patient_id=" eXYZ123 ", + status="finished", + date="2024-01-01", + ) + + enc_response = MagicMock() + enc_response.status_code = 200 + enc_response.json.return_value = { + "resourceType": "Bundle", + "total": 1, + "entry": [{"resource": {"resourceType": "Encounter", "id": "enc-explicit"}}], + } + + with patch( + "httpx.AsyncClient.get", new_callable=AsyncMock, return_value=enc_response + ) as mock_get: + result = await c.internal_execute(params, trace_id="test-trace") + + assert result.total == 1 + assert result.resources[0]["id"] == "enc-explicit" + call_kwargs = mock_get.call_args + sent_params = call_kwargs.kwargs.get("params") or call_kwargs[1].get("params", {}) + assert sent_params.get("patient") == "eXYZ123" + assert sent_params.get("status") == "finished" + assert sent_params.get("date") == "2024-01-01" + + +@pytest.mark.asyncio +async def test_fhir_epic_search_encounter_no_params_raises(): + c = _connector() + from node_wire_fhir_epic.schema import FhirEncounterSearchInput + + params = FhirEncounterSearchInput(action="search_encounter") + + with pytest.raises(ValueError, match="Provide at least patient_id"): + await c.internal_execute(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_fhir_epic_search_encounter_http_status_error(): + c = _connector() + from node_wire_fhir_epic.schema import FhirEncounterSearchInput + + params = FhirEncounterSearchInput( + action="search_encounter", + search_params={"patient": "eXYZ123"}, + ) + + request = httpx.Request("GET", "https://fhir.epic.com/api/FHIR/R4/Encounter") + response = httpx.Response(403, request=request, text="Forbidden") + http_error = httpx.HTTPStatusError("Forbidden", request=request, response=response) + + error_resp = MagicMock() + error_resp.raise_for_status.side_effect = http_error + + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=error_resp): + with pytest.raises(httpx.HTTPStatusError): + await c.internal_execute(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_fhir_epic_search_encounter_generic_error(): + c = _connector() + from node_wire_fhir_epic.schema import FhirEncounterSearchInput + + params = FhirEncounterSearchInput( + action="search_encounter", + search_params={"patient": "eXYZ123"}, + ) + + with patch( + "httpx.AsyncClient.get", + new_callable=AsyncMock, + side_effect=httpx.ConnectError("connection refused"), + ): + with pytest.raises(httpx.ConnectError): + await c.internal_execute(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_fhir_epic_create_document_reference_with_optional_fields(): + c = _connector() + from node_wire_fhir_epic.schema import FhirDocumentReferenceCreateInput + + params = FhirDocumentReferenceCreateInput( + action="create_document_reference", + identifier=[{"system": "urn:oid:1.2.3", "value": "ID.123"}], + status="current", + type={"coding": [{"system": "urn:oid:4.5.6", "code": "18100", "display": "Scan"}]}, + subject="Patient/ePD0eeFq.GMHG.aXttqP.Lw3", + data="dGVzdA==", + category=[{"coding": [{"code": "clinical-note"}]}], + author=[{"reference": "Practitioner/p1"}], + description="Test document", + context={"related": [{"reference": "Group/eqv3buSV"}]}, + additional_fields={"custodian": {"reference": "Organization/org1"}}, + ) + + create_response = MagicMock() + create_response.status_code = 201 + create_response.headers = { + "Location": "https://fhir.epic.com/api/FHIR/R4/DocumentReference/doc-opt/_history/1" + } + create_response.content = b"" + create_response.text = "" + + with patch( + "httpx.AsyncClient.post", new_callable=AsyncMock, return_value=create_response + ) as mock_post: + result = await c.internal_execute(params, trace_id="test-trace") + + assert result.resource_id == "doc-opt" + payload = mock_post.call_args.kwargs["json"] + assert payload["category"] == params.category + assert payload["author"] == params.author + assert payload["description"] == "Test document" + assert payload["custodian"] == {"reference": "Organization/org1"} + + +@pytest.mark.asyncio +async def test_fhir_epic_create_document_reference_malformed_json_body(): + c = _connector() + from node_wire_fhir_epic.schema import FhirDocumentReferenceCreateInput + + params = FhirDocumentReferenceCreateInput( + action="create_document_reference", + identifier=[{"system": "urn:oid:1.2.3", "value": "ID.123"}], + status="current", + type={"coding": [{"system": "urn:oid:4.5.6", "code": "18100"}]}, + subject="Patient/ePD0eeFq.GMHG.aXttqP.Lw3", + data="dGVzdA==", + ) + + create_response = MagicMock() + create_response.status_code = 201 + create_response.headers = {"content-length": "10"} + create_response.content = b"not-json" + create_response.json.side_effect = ValueError("invalid json") + + with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=create_response): + with pytest.raises(ValueError, match="Could not extract resource ID"): + await c.internal_execute(params, trace_id="test-trace") + + +# --------------------------------------------------------------------------- +# read_patient — empty Bundle / HTTP errors +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_fhir_epic_read_patient_empty_bundle_raises(): + c = _connector() + from node_wire_fhir_epic.schema import FhirPatientReadInput + + params = FhirPatientReadInput(action="read_patient", name="Nobody") + + empty_bundle = MagicMock() + empty_bundle.status_code = 200 + empty_bundle.json.return_value = {"resourceType": "Bundle", "total": 0, "entry": []} + + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=empty_bundle): + with pytest.raises(ValueError, match="No patients found"): + await c.internal_execute(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_fhir_epic_read_patient_http_error(): + c = _connector() + from node_wire_fhir_epic.schema import FhirPatientReadInput + + params = FhirPatientReadInput(action="read_patient", resource_id="eFAIL") + + error_resp = MagicMock() + error_resp.status_code = 500 + error_resp.raise_for_status.side_effect = Exception("server error") + + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=error_resp): + with pytest.raises(Exception, match="server error"): + await c.internal_execute(params, trace_id="test-trace") + + +# --------------------------------------------------------------------------- +# search_patients — HTTPStatusError vs generic error +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_fhir_epic_search_patients_name_search_http_status_error(): + c = _connector() + from node_wire_fhir_epic.schema import FhirPatientSearchInput + + params = FhirPatientSearchInput(action="search_patients", family_name="Smith") + + request = httpx.Request("GET", "https://fhir.epic.com/api/FHIR/R4/Patient") + response = httpx.Response(403, request=request, text="Forbidden") + http_error = httpx.HTTPStatusError("Forbidden", request=request, response=response) + + error_resp = MagicMock() + error_resp.raise_for_status.side_effect = http_error + + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=error_resp): + with pytest.raises(httpx.HTTPStatusError): + await c.internal_execute(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_fhir_epic_search_patients_name_search_generic_error(): + c = _connector() + from node_wire_fhir_epic.schema import FhirPatientSearchInput + + params = FhirPatientSearchInput(action="search_patients", family_name="Smith") + + with patch( + "httpx.AsyncClient.get", + new_callable=AsyncMock, + side_effect=httpx.ConnectError("connection refused"), + ): + with pytest.raises(httpx.ConnectError): + await c.internal_execute(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_fhir_epic_search_patients_empty_resource_ids_raises(): + c = _connector() + from node_wire_fhir_epic.schema import FhirPatientSearchInput + + params = FhirPatientSearchInput(action="search_patients", resource_ids=[" ", ""]) + + with pytest.raises(ValueError, match="resource_ids list is empty"): + await c.internal_execute(params, trace_id="test-trace") + + +# --------------------------------------------------------------------------- +# create_document_reference — error paths and ID extraction +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_fhir_epic_create_document_reference_operation_outcome_error(): + c = _connector() + from node_wire_fhir_epic.schema import FhirDocumentReferenceCreateInput + + params = FhirDocumentReferenceCreateInput( + action="create_document_reference", + identifier=[{"system": "urn:oid:1.2.3", "value": "ID.123"}], + status="current", + type={"coding": [{"system": "urn:oid:4.5.6", "code": "18100"}]}, + subject="Patient/ePD0eeFq.GMHG.aXttqP.Lw3", + data="dGVzdA==", + ) + + request = httpx.Request("POST", "https://fhir.epic.com/api/FHIR/R4/DocumentReference") + response = httpx.Response( + 422, + request=request, + json={ + "resourceType": "OperationOutcome", + "issue": [{"diagnostics": "Invalid subject reference"}], + }, + ) + http_error = httpx.HTTPStatusError("Unprocessable", request=request, response=response) + + error_resp = MagicMock() + error_resp.raise_for_status.side_effect = http_error + + with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=error_resp): + with pytest.raises(ValueError, match="Epic Error: Invalid subject reference"): + await c.internal_execute(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_fhir_epic_create_document_reference_id_from_json_body(): + c = _connector() + from node_wire_fhir_epic.schema import FhirDocumentReferenceCreateInput + + params = FhirDocumentReferenceCreateInput( + action="create_document_reference", + identifier=[{"system": "urn:oid:1.2.3", "value": "ID.123"}], + status="current", + type={"coding": [{"system": "urn:oid:4.5.6", "code": "18100"}]}, + subject="Patient/ePD0eeFq.GMHG.aXttqP.Lw3", + data="dGVzdA==", + ) + + create_response = MagicMock() + create_response.status_code = 201 + create_response.headers = {"content-length": "42"} + create_response.content = b'{"id":"doc-from-body"}' + create_response.json.return_value = {"id": "doc-from-body"} + + with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=create_response): + result = await c.internal_execute(params, trace_id="test-trace") + + assert result.resource_id == "doc-from-body" + assert result.resource == {"id": "doc-from-body"} + + +@pytest.mark.asyncio +async def test_fhir_epic_create_document_reference_no_id_raises(): + c = _connector() + from node_wire_fhir_epic.schema import FhirDocumentReferenceCreateInput + + params = FhirDocumentReferenceCreateInput( + action="create_document_reference", + identifier=[{"system": "urn:oid:1.2.3", "value": "ID.123"}], + status="current", + type={"coding": [{"system": "urn:oid:4.5.6", "code": "18100"}]}, + subject="Patient/ePD0eeFq.GMHG.aXttqP.Lw3", + data="dGVzdA==", + ) + + create_response = MagicMock() + create_response.status_code = 201 + create_response.headers = {"content-length": "0"} + create_response.content = b"" + + with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=create_response): + with pytest.raises(ValueError, match="Could not extract resource ID"): + await c.internal_execute(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_fhir_epic_create_document_reference_generic_error(): + c = _connector() + from node_wire_fhir_epic.schema import FhirDocumentReferenceCreateInput + + params = FhirDocumentReferenceCreateInput( + action="create_document_reference", + identifier=[{"system": "urn:oid:1.2.3", "value": "ID.123"}], + status="current", + type={"coding": [{"system": "urn:oid:4.5.6", "code": "18100"}]}, + subject="Patient/ePD0eeFq.GMHG.aXttqP.Lw3", + data="dGVzdA==", + ) + + with patch( + "httpx.AsyncClient.post", + new_callable=AsyncMock, + side_effect=httpx.ConnectError("connection refused"), + ): + with pytest.raises(httpx.ConnectError): + await c.internal_execute(params, trace_id="test-trace") + + +# --------------------------------------------------------------------------- +# search_document_reference — error paths +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_fhir_epic_search_document_reference_http_status_error(): + c = _connector() + from node_wire_fhir_epic.schema import FhirDocumentReferenceSearchInput + + params = FhirDocumentReferenceSearchInput( + action="search_document_reference", + search_params={"patient": "eXYZ123"}, + ) + + request = httpx.Request("GET", "https://fhir.epic.com/api/FHIR/R4/DocumentReference") + response = httpx.Response(403, request=request, text="Forbidden") + http_error = httpx.HTTPStatusError("Forbidden", request=request, response=response) + + error_resp = MagicMock() + error_resp.raise_for_status.side_effect = http_error + + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=error_resp): + with pytest.raises(httpx.HTTPStatusError): + await c.internal_execute(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_fhir_epic_search_document_reference_generic_error(): + c = _connector() + from node_wire_fhir_epic.schema import FhirDocumentReferenceSearchInput + + params = FhirDocumentReferenceSearchInput( + action="search_document_reference", + search_params={"patient": "eXYZ123"}, + ) + + with patch( + "httpx.AsyncClient.get", + new_callable=AsyncMock, + side_effect=httpx.ConnectError("connection refused"), + ): + with pytest.raises(httpx.ConnectError): + await c.internal_execute(params, trace_id="test-trace") diff --git a/tests/test_google_drive.py b/tests/test_google_drive.py index e1bc456..0a8ba8c 100644 --- a/tests/test_google_drive.py +++ b/tests/test_google_drive.py @@ -177,3 +177,10 @@ async def _raise_http_error(*_args: object, **_kwargs: object) -> None: ): with pytest.raises(expected_exception): asyncio.run(connector.internal_execute(params, trace_id="test-trace")) + + +@pytest.mark.asyncio +async def test_google_drive_unknown_action_spec_raises(): + connector = _connector() + with pytest.raises(ValueError, match="No action spec registered"): + await connector._execute_action_spec("not_a_real_action", {}, trace_id="test-trace") diff --git a/tests/test_salesforce.py b/tests/test_salesforce.py index 9aa241f..da80a8c 100644 --- a/tests/test_salesforce.py +++ b/tests/test_salesforce.py @@ -280,3 +280,21 @@ async def test_salesforce_delete_lead_happy_path(): assert result.success is True assert result.resource_id == "00Q123456789012" assert mock_request.call_args[0][0] == "DELETE" + + +@pytest.mark.asyncio +async def test_salesforce_read_contact_non_json_response_uses_text(): + connector = _connector() + params = ReadContactInput(record_id="003123456789012") + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.content = b"not-json-body" + mock_response.text = "not-json-body" + mock_response.json.side_effect = ValueError("invalid json") + + with patch("httpx.AsyncClient.request", return_value=mock_response): + result = await connector.read_contact(params, trace_id="test-trace") + + assert result.success is True + assert result.data == {"text": "not-json-body"} diff --git a/tests/test_slack_connector.py b/tests/test_slack_connector.py index be219e6..a60dade 100644 --- a/tests/test_slack_connector.py +++ b/tests/test_slack_connector.py @@ -258,6 +258,30 @@ async def test_send_direct_message_success() -> None: assert result.data["ok"] is True +@pytest.mark.asyncio +async def test_send_direct_message_with_blocks() -> None: + connector = _make_connector() + blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": "dm blocks"}}] + captured: dict[str, Any] = {} + + async def fake_post_json(url: str, token: str, body: dict) -> dict: + captured.update(body) + return {**_slack_ok_response(), "channel": _USER_ID} + + with patch("node_wire_slack.logic._post_json", new=fake_post_json): + result = await connector.run( + { + "action": "send_direct_message", + "channel": _USER_ID, + "message": "Hi", + "blocks": blocks, + } + ) + + assert result.success is True + assert captured.get("blocks") == blocks + + # --------------------------------------------------------------------------- # 8. upload_file — base64 happy path # --------------------------------------------------------------------------- @@ -295,6 +319,31 @@ async def test_upload_file_base64_success() -> None: assert result.data["file_id"] == file_id +@pytest.mark.asyncio +async def test_upload_file_missing_filepath_in_sandbox_returns_business_error( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + attachments_dir = tmp_path / "attachments" + attachments_dir.mkdir() + monkeypatch.setattr("node_wire_slack.logic._ATTACHMENTS_DIR", str(attachments_dir)) + + connector = _make_connector() + missing = attachments_dir / "missing.txt" + + result = await connector.run( + { + "action": "upload_file", + "channel": _CHANNEL, + "filepath": str(missing), + } + ) + + assert result.success is False + assert result.error_code == "SLACK_UPLOAD_ERROR" + assert "No such file" in result.message + + # --------------------------------------------------------------------------- # 9. upload_file — missing content source # --------------------------------------------------------------------------- @@ -421,6 +470,10 @@ def test_resolve_blocks_non_array_json_raises() -> None: _resolve_blocks(json.dumps({"type": "section"})) +def test_resolve_blocks_empty_string_returns_none() -> None: + assert _resolve_blocks(" ") is None + + @pytest.mark.asyncio @pytest.mark.parametrize("channel_id", ["", "#general", "U0TEST456"]) async def test_complete_upload_omits_invalid_channel_id(channel_id: str) -> None: @@ -643,3 +696,209 @@ def test_default_timeout_honors_env(monkeypatch: pytest.MonkeyPatch) -> None: # Restore the module to its default-env state for the rest of the suite. monkeypatch.delenv("NW_SLACK_TIMEOUT", raising=False) importlib.reload(slack_logic) + + +# --------------------------------------------------------------------------- +# 14. _post_json — Slack API error translation via ok:false payloads +# --------------------------------------------------------------------------- + + +def _fake_slack_client(response_json: dict[str, Any], status_code: int = 200): + """Build a fake httpx.AsyncClient that returns a fixed JSON body.""" + + class FakeResponse: + def __init__(self) -> None: + self.status_code = status_code + + def json(self) -> dict[str, Any]: + return response_json + + class FakeAsyncClient: + def __init__(self, timeout: float) -> None: + self.timeout = timeout + + async def __aenter__(self) -> "FakeAsyncClient": + return self + + async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None: + return None + + async def post(self, *args: object, **kwargs: object) -> FakeResponse: + return FakeResponse() + + return FakeAsyncClient + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "error_code,expected_exc", + [ + ("invalid_auth", SlackAuthError), + ("missing_scope", SlackPermissionError), + ("ratelimited", SlackRateLimitError), + ("channel_not_found", SlackMessageError), + ], +) +async def test_post_json_ok_false_raises_typed_exception( + error_code: str, expected_exc: type[Exception] +) -> None: + from node_wire_slack.logic import _post_json + + payload = { + "ok": False, + "error": error_code, + "response_metadata": {"messages": ["detail message"]}, + } + + with patch("node_wire_slack.logic.httpx.AsyncClient", new=_fake_slack_client(payload)): + with pytest.raises(expected_exc): + await _post_json("https://slack.com/api/chat.postMessage", _FAKE_TOKEN, {"text": "hi"}) + + +@pytest.mark.asyncio +async def test_post_json_http_429_raises_rate_limit() -> None: + from node_wire_slack.logic import _post_json + + payload = {"ok": False, "error": "some_other_error"} + + with patch( + "node_wire_slack.logic.httpx.AsyncClient", + new=_fake_slack_client(payload, status_code=429), + ): + with pytest.raises(SlackRateLimitError): + await _post_json("https://slack.com/api/chat.postMessage", _FAKE_TOKEN, {"text": "hi"}) + + +@pytest.mark.asyncio +async def test_complete_upload_ok_false_raises() -> None: + payload = {"ok": False, "error": "invalid_auth"} + + with patch("node_wire_slack.logic.httpx.AsyncClient", new=_fake_slack_client(payload)): + with pytest.raises(SlackAuthError): + await _complete_upload(_FAKE_TOKEN, "F0TEST", "title.txt", channel_id=_CHANNEL) + + +# --------------------------------------------------------------------------- +# 15. _resolve_channel_id — user DM success and ok:false fallback +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_resolve_channel_id_user_success(monkeypatch: pytest.MonkeyPatch) -> None: + from node_wire_slack.logic import _resolve_channel_id + + monkeypatch.delenv("NW_SLACK_SKIP_RESOLVE", raising=False) + + dm_channel = "D0DMCHANNEL" + + class FakeAsyncClient: + def __init__(self, timeout: float) -> None: + self.timeout = timeout + + async def __aenter__(self) -> "FakeAsyncClient": + return self + + async def __aexit__(self, *args: object) -> None: + return None + + async def post(self, *args: object, **kwargs: object) -> object: + class FakeResponse: + status_code = 200 + + def json(self) -> dict[str, object]: + return {"ok": True, "channel": {"id": dm_channel}} + + return FakeResponse() + + with patch("node_wire_slack.logic.httpx.AsyncClient", new=FakeAsyncClient): + resolved = await _resolve_channel_id(_FAKE_TOKEN, "U12345678") + + assert resolved == dm_channel + + +@pytest.mark.asyncio +async def test_resolve_channel_id_user_ok_false_falls_back( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from node_wire_slack.logic import _resolve_channel_id + + monkeypatch.delenv("NW_SLACK_SKIP_RESOLVE", raising=False) + user_id = "U87654321" + + class FakeAsyncClient: + def __init__(self, timeout: float) -> None: + self.timeout = timeout + + async def __aenter__(self) -> "FakeAsyncClient": + return self + + async def __aexit__(self, *args: object) -> None: + return None + + async def post(self, *args: object, **kwargs: object) -> object: + class FakeResponse: + status_code = 200 + + def json(self) -> dict[str, object]: + return {"ok": False, "error": "user_not_found"} + + return FakeResponse() + + with patch("node_wire_slack.logic.httpx.AsyncClient", new=FakeAsyncClient): + resolved = await _resolve_channel_id(_FAKE_TOKEN, user_id) + + assert resolved == user_id + + +@pytest.mark.asyncio +async def test_resolve_channel_id_empty_target_returns_empty() -> None: + from node_wire_slack.logic import _resolve_channel_id + + assert await _resolve_channel_id(_FAKE_TOKEN, " ") == "" + + +# --------------------------------------------------------------------------- +# 16. Upload path and limit helpers +# --------------------------------------------------------------------------- + + +def test_resolve_upload_path_relative_raises(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + from node_wire_slack.logic import _resolve_upload_path + + attachments_dir = tmp_path / "attachments" + attachments_dir.mkdir() + monkeypatch.setattr("node_wire_slack.logic._ATTACHMENTS_DIR", str(attachments_dir)) + + with pytest.raises(SlackUploadError, match="must be an absolute path"): + _resolve_upload_path("relative/note.txt") + + +def test_resolve_upload_path_escape_raises(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + from node_wire_slack.logic import _resolve_upload_path + + attachments_dir = tmp_path / "attachments" + attachments_dir.mkdir() + monkeypatch.setattr("node_wire_slack.logic._ATTACHMENTS_DIR", str(attachments_dir)) + + outside = tmp_path / "outside.txt" + outside.write_text("secret") + + with pytest.raises(SlackUploadError, match="must be under"): + _resolve_upload_path(str(outside)) + + +def test_get_upload_limit_bytes_invalid_env_uses_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from node_wire_slack.logic import _get_upload_limit_bytes + + monkeypatch.setenv("NW_SLACK_UPLOAD_LIMIT_MB", "not-a-number") + assert _get_upload_limit_bytes() == 50 * 1024 * 1024 + + +@pytest.mark.asyncio +async def test_resolve_channel_id_z_prefix_passthrough() -> None: + from node_wire_slack.logic import _resolve_channel_id + + channel = "Z0123456789" + assert await _resolve_channel_id(_FAKE_TOKEN, channel) == channel diff --git a/tests/test_smtp_relay.py b/tests/test_smtp_relay.py index da7b38c..70fe8b3 100644 --- a/tests/test_smtp_relay.py +++ b/tests/test_smtp_relay.py @@ -113,3 +113,114 @@ async def _run() -> None: asyncio.run(_run()) assert send_called is False + + +def test_resolve_smtp_relay_empty_host_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SMTP_HOST", "") + + with pytest.raises(SmtpRelayNotAllowedError, match="SMTP_HOST is empty"): + resolve_smtp_relay() + + +def test_resolve_smtp_relay_invalid_port_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SMTP_HOST", "smtp.example.com") + monkeypatch.setenv("SMTP_PORT", "not-a-port") + + with pytest.raises(SmtpRelayNotAllowedError, match="port is invalid"): + resolve_smtp_relay() + + +def test_smtp_connector_uses_auth_provider_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + from node_wire_runtime.auth.base import AuthProvider + + class CredsAuthProvider(AuthProvider): + async def get_client_credentials(self) -> tuple[str, str]: + return ("auth-user", "auth-pass") + + async def get_headers(self) -> dict[str, str]: + return {} + + monkeypatch.setenv("SMTP_HOST", "smtp.example.com") + monkeypatch.setenv("SMTP_PORT", "465") + monkeypatch.setenv("SMTP_USE_TLS", "false") + + captured: dict[str, object] = {} + + async def fake_send(*args: object, **kwargs: object) -> tuple[int, str]: + captured.update(kwargs) + return (250, "OK") + + async def _run() -> None: + with patch("node_wire_smtp.logic.aiosmtplib.send", new=fake_send): + connector = SmtpConnector(auth_provider=CredsAuthProvider()) + inp = SmtpSendInput( + from_email="a@example.com", + to=["b@example.com"], + subject="s", + body="hi", + ) + out = await connector.internal_execute(inp, trace_id="t-auth") + assert out.sent is True + + asyncio.run(_run()) + assert captured["username"] == "auth-user" + assert captured["password"] == "auth-pass" + assert captured["use_tls"] is True + assert captured["start_tls"] is False + + +def test_smtp_connector_send_failure_logs_and_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SMTP_HOST", "smtp.example.com") + monkeypatch.setenv("SMTP_PORT", "587") + monkeypatch.setenv("SMTP_USE_TLS", "true") + + async def failing_send(*args: object, **kwargs: object) -> tuple[int, str]: + raise ConnectionError("smtp down") + + async def _run() -> None: + with patch("node_wire_smtp.logic.aiosmtplib.send", new=failing_send): + connector = SmtpConnector() + inp = SmtpSendInput( + from_email="a@example.com", + to=["b@example.com"], + subject="s", + body="hi", + ) + with pytest.raises(ConnectionError, match="smtp down"): + await connector.internal_execute(inp, trace_id="t-fail") + + asyncio.run(_run()) + + +def test_smtp_connector_falls_back_to_env_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + class FailingSecrets: + def get_secret(self, key: str) -> str: + raise KeyError(key) + + monkeypatch.setenv("SMTP_HOST", "smtp.example.com") + monkeypatch.setenv("SMTP_PORT", "587") + monkeypatch.setenv("SMTP_USE_TLS", "true") + monkeypatch.setenv("SMTP_USERNAME", "env-user") + monkeypatch.setenv("SMTP_PASSWORD", "env-pass") + + captured: dict[str, object] = {} + + async def fake_send(*args: object, **kwargs: object) -> tuple[int, str]: + captured.update(kwargs) + return (250, "OK") + + async def _run() -> None: + with patch("node_wire_smtp.logic.aiosmtplib.send", new=fake_send): + connector = SmtpConnector(secret_provider=FailingSecrets()) + inp = SmtpSendInput( + from_email="a@example.com", + to=["b@example.com"], + subject="s", + body="hi", + ) + out = await connector.internal_execute(inp, trace_id="t-env") + assert out.sent is True + + asyncio.run(_run()) + assert captured["username"] == "env-user" + assert captured["password"] == "env-pass" diff --git a/tests/test_stripe.py b/tests/test_stripe.py index 692876f..fa1e51c 100644 --- a/tests/test_stripe.py +++ b/tests/test_stripe.py @@ -69,6 +69,20 @@ async def test_stripe_charge_happy_path(): ) +@pytest.mark.asyncio +async def test_stripe_charge_unpaid_returns_failed_status(): + connector = _connector() + params = ChargeInput(amount=1000, currency="usd", source="tok_visa") + + mock_charge = MagicMock(id="ch_456", receipt_url=None, paid=False) + + with patch("stripe.Charge.create", return_value=mock_charge): + result = await connector.charge(params, trace_id="test-trace") + + assert result.charge_id == "ch_456" + assert result.status == "failed" + + # --------------------------------------------------------------------------- # Create Payment Intent # --------------------------------------------------------------------------- @@ -134,6 +148,100 @@ async def test_stripe_create_subscription_with_card_token(): assert mock_sub_create.call_args.kwargs["idempotency_key"] == "test-trace" +@pytest.mark.asyncio +async def test_stripe_create_subscription_with_default_payment_method(): + connector = _connector() + params = CreateSubscriptionInput( + customer_id="cus_123", + price_id="price_abc", + default_payment_method="pm_existing", + ) + + mock_sub = MagicMock( + id="sub_456", status="active", pending_setup_intent=None, latest_invoice=None + ) + + with patch("stripe.Subscription.create", return_value=mock_sub) as mock_sub_create: + result = await connector.create_subscription(params, trace_id="test-trace") + + assert result.subscription_id == "sub_456" + assert mock_sub_create.call_args.kwargs["default_payment_method"] == "pm_existing" + + +@pytest.mark.asyncio +async def test_stripe_create_subscription_pending_setup_intent_client_secret(): + connector = _connector() + params = CreateSubscriptionInput(customer_id="cus_123", price_id="price_abc") + + mock_sub = MagicMock( + id="sub_si", + status="incomplete", + pending_setup_intent="seti_abc", + latest_invoice=None, + ) + mock_si = MagicMock(client_secret="seti_secret_xyz") + + with ( + patch("stripe.Subscription.create", return_value=mock_sub), + patch("stripe.SetupIntent.retrieve", return_value=mock_si) as mock_si_retrieve, + ): + result = await connector.create_subscription(params, trace_id="test-trace") + + assert result.client_secret == "seti_secret_xyz" + mock_si_retrieve.assert_called_once_with("seti_abc", api_key="sk_test_mock") + + +@pytest.mark.asyncio +async def test_stripe_create_subscription_latest_invoice_payment_intent_client_secret(): + connector = _connector() + params = CreateSubscriptionInput(customer_id="cus_123", price_id="price_abc") + + mock_sub = MagicMock( + id="sub_inv", + status="incomplete", + pending_setup_intent=None, + latest_invoice="in_abc", + ) + mock_inv = MagicMock(payment_intent="pi_abc") + mock_pi = MagicMock(client_secret="pi_secret_xyz") + + with ( + patch("stripe.Subscription.create", return_value=mock_sub), + patch("stripe.Invoice.retrieve", return_value=mock_inv) as mock_inv_retrieve, + patch("stripe.PaymentIntent.retrieve", return_value=mock_pi) as mock_pi_retrieve, + ): + result = await connector.create_subscription(params, trace_id="test-trace") + + assert result.client_secret == "pi_secret_xyz" + mock_inv_retrieve.assert_called_once_with("in_abc", api_key="sk_test_mock") + mock_pi_retrieve.assert_called_once_with("pi_abc", api_key="sk_test_mock") + + +@pytest.mark.asyncio +async def test_stripe_create_subscription_setup_intent_object_id(): + """Cover _stripe_obj_id when pending_setup_intent is an object, not a string.""" + connector = _connector() + params = CreateSubscriptionInput(customer_id="cus_123", price_id="price_abc") + + setup_intent_obj = MagicMock(id="seti_obj") + mock_sub = MagicMock( + id="sub_obj", + status="incomplete", + pending_setup_intent=setup_intent_obj, + latest_invoice=None, + ) + mock_si = MagicMock(client_secret="seti_obj_secret") + + with ( + patch("stripe.Subscription.create", return_value=mock_sub), + patch("stripe.SetupIntent.retrieve", return_value=mock_si) as mock_si_retrieve, + ): + result = await connector.create_subscription(params, trace_id="test-trace") + + assert result.client_secret == "seti_obj_secret" + mock_si_retrieve.assert_called_once_with("seti_obj", api_key="sk_test_mock") + + # --------------------------------------------------------------------------- # Cancel Subscription # --------------------------------------------------------------------------- @@ -156,6 +264,26 @@ async def test_stripe_cancel_subscription_immediate(): ) +@pytest.mark.asyncio +async def test_stripe_cancel_subscription_at_period_end(): + connector = _connector() + params = CancelSubscriptionInput(subscription_id="sub_123", cancel_at_period_end=True) + + mock_sub = MagicMock(id="sub_123", status="active") + + with patch("stripe.Subscription.modify", return_value=mock_sub) as mock_modify: + result = await connector.cancel_subscription(params, trace_id="test-trace") + + assert result.subscription_id == "sub_123" + assert result.status == "active" + mock_modify.assert_called_once_with( + "sub_123", + api_key="sk_test_mock", + cancel_at_period_end=True, + idempotency_key="test-trace", + ) + + # --------------------------------------------------------------------------- # Issue Refund # --------------------------------------------------------------------------- @@ -294,3 +422,93 @@ async def test_stripe_run_maps_authentication_error_to_auth(): assert result.success is False assert result.error_category == ErrorCategory.AUTH assert result.error_code == "STRIPE_AUTH_ERROR" + + +# --------------------------------------------------------------------------- +# Exception logging branches +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_stripe_create_payment_intent_failure_logs_and_raises(): + connector = _connector() + params = CreatePaymentIntentInput(amount=1000, currency="usd") + + with patch("stripe.PaymentIntent.create", side_effect=RuntimeError("stripe down")): + with pytest.raises(RuntimeError, match="stripe down"): + await connector.create_payment_intent(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_stripe_create_subscription_failure_logs_and_raises(): + connector = _connector() + params = CreateSubscriptionInput(customer_id="cus_123", price_id="price_abc") + + with patch("stripe.Subscription.create", side_effect=RuntimeError("sub failed")): + with pytest.raises(RuntimeError, match="sub failed"): + await connector.create_subscription(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_stripe_cancel_subscription_failure_logs_and_raises(): + connector = _connector() + params = CancelSubscriptionInput(subscription_id="sub_123") + + with patch("stripe.Subscription.cancel", side_effect=RuntimeError("cancel failed")): + with pytest.raises(RuntimeError, match="cancel failed"): + await connector.cancel_subscription(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_stripe_issue_refund_failure_logs_and_raises(): + connector = _connector() + params = IssueRefundInput(payment_intent_id="pi_123") + + with patch("stripe.Refund.create", side_effect=RuntimeError("refund failed")): + with pytest.raises(RuntimeError, match="refund failed"): + await connector.issue_refund(params, trace_id="test-trace") + + +# --------------------------------------------------------------------------- +# Exception logging branches +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_stripe_create_payment_intent_failure_logs_and_raises(): + connector = _connector() + params = CreatePaymentIntentInput(amount=1000, currency="usd") + + with patch("stripe.PaymentIntent.create", side_effect=RuntimeError("stripe down")): + with pytest.raises(RuntimeError, match="stripe down"): + await connector.create_payment_intent(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_stripe_create_subscription_failure_logs_and_raises(): + connector = _connector() + params = CreateSubscriptionInput(customer_id="cus_123", price_id="price_abc") + + with patch("stripe.Subscription.create", side_effect=RuntimeError("sub failed")): + with pytest.raises(RuntimeError, match="sub failed"): + await connector.create_subscription(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_stripe_cancel_subscription_failure_logs_and_raises(): + connector = _connector() + params = CancelSubscriptionInput(subscription_id="sub_123") + + with patch("stripe.Subscription.cancel", side_effect=RuntimeError("cancel failed")): + with pytest.raises(RuntimeError, match="cancel failed"): + await connector.cancel_subscription(params, trace_id="test-trace") + + +@pytest.mark.asyncio +async def test_stripe_issue_refund_failure_logs_and_raises(): + connector = _connector() + params = IssueRefundInput(payment_intent_id="pi_123") + + with patch("stripe.Refund.create", side_effect=RuntimeError("refund failed")): + with pytest.raises(RuntimeError, match="refund failed"): + await connector.issue_refund(params, trace_id="test-trace") diff --git a/uv.lock b/uv.lock index ae9acad..a493fad 100644 --- a/uv.lock +++ b/uv.lock @@ -2150,7 +2150,7 @@ wheels = [ [[package]] name = "node-wire" -version = "0.1.0" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "aiosmtplib" }, From ea04f86768007450abe24548457f437e8ddadb8d Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:18:22 -0700 Subject: [PATCH 08/20] Checklist gaps are implemented - Rollback / unpublish runbook - Secret scanning automation - Cross-platform tests --- .github/workflows/pytest.yml | 7 +- .github/workflows/secret-scan.yml | 35 ++++++++ README.md | 1 + docs/packaging.md | 4 + docs/quality-security-gates.md | 51 +++++++++-- docs/release-rollback.md | 144 ++++++++++++++++++++++++++++++ 6 files changed, 234 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/secret-scan.yml create mode 100644 docs/release-rollback.md diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index f8f8e6a..994847b 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -15,12 +15,13 @@ on: jobs: test: - name: Run pytest (Python ${{ matrix.python-version }}) - runs-on: ubuntu-latest + name: Run pytest (${{ matrix.os }}, Python ${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: + os: [ubuntu-latest, macos-latest, windows-latest] python-version: ["3.11", "3.12"] steps: @@ -50,7 +51,7 @@ jobs: if: always() uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: - name: coverage-html-py${{ matrix.python-version }} + name: coverage-html-${{ matrix.os }}-py${{ matrix.python-version }} path: htmlcov/ if-no-files-found: ignore diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000..a222bf2 --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,35 @@ + +## +## SPDX-FileCopyrightText: 2026 AOT Technologies +## SPDX-License-Identifier: Apache-2.0 +## + +name: Secret scan + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + schedule: + # Weekly — catches newly disclosed patterns against full history. + - cron: "0 7 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + gitleaks: + name: Gitleaks secret scan + runs-on: ubuntu-latest + steps: + - name: Checkout repository (full history) + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Run Gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index e9799ce..4f59115 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ For more detailed information, please refer to the following guides: - **[Troubleshooting](docs/troubleshooting.md)** — Common errors and fixes. - **[MCP Servers & Docker](docs/mcp-servers.md)** — Deploying individual connectors as MCP servers. - **[Packaging & Publishing](docs/packaging.md)** — Wheel builds and CI flow. +- **[Release Rollback](docs/release-rollback.md)** — PyPI yank and corrective release procedure. - **[Code Quality & Compliance](docs/code-quality-compliance.md)** — Ruff, Mypy, pre-commit, REUSE, and dependency compliance. - **[Privacy](docs/privacy.md)** — Data handling and logging guidance. - **[HIPAA Considerations](docs/compliance/hipaa-considerations.md)** — Deploying Node Wire in regulated healthcare environments. diff --git a/docs/packaging.md b/docs/packaging.md index 7fa464f..c1ea690 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -190,6 +190,10 @@ pip install "node-wire-runtime[gcp]" # google-cloud-secret-manager 5. Generate SBOM via `cyclonedx-py` 6. Publish to PyPI via OIDC Trusted Publisher with Sigstore attestations (all action SHAs pinned for immutability) +If a published release must be withdrawn or replaced, follow +[release-rollback.md](release-rollback.md) (PyPI yank, corrective patch release, +and GitHub tag/release handling). + --- ## Docker demo images diff --git a/docs/quality-security-gates.md b/docs/quality-security-gates.md index a6a6f53..c5f1d04 100644 --- a/docs/quality-security-gates.md +++ b/docs/quality-security-gates.md @@ -24,17 +24,31 @@ Runs GitHub CodeQL static analysis for Python on pull requests, pushes to `main` Workflow: `.github/workflows/pytest.yml` -Runs the full test suite (Python 3.11 and 3.12 matrix) with coverage on every pull request and push to `main`/`master`. +Runs the full test suite on **Linux, macOS, and Windows** (Python 3.11 and 3.12 +matrix) with coverage on every pull request and push to `main`/`master`. +Playground integration tests remain manual (`workflow_dispatch`) on Ubuntu only. Workflow: `.github/workflows/lint.yml` also runs `lockfile-check` (`uv lock --check`) to fail PRs when `pyproject.toml` changes without an updated `uv.lock`. +Workflow: `.github/workflows/secret-scan.yml` + +Runs [Gitleaks](https://github.com/gitleaks/gitleaks) on pull requests, pushes to +`main`/`master`, weekly (Mondays), and on manual dispatch. The workflow checks +out **full git history** (`fetch-depth: 0`) so secrets in past commits are +scanned, not only the working tree. + Required checks to add in branch protection: - `Lint and Type Check / Lockfile freshness` - `Quality gates / Bandit security scan` - `CodeQL / Analyze (Python)` -- `CI – Pytest / Run pytest (Python 3.11)` -- `CI – Pytest / Run pytest (Python 3.12)` +- `Secret scan / Gitleaks secret scan` +- `CI – Pytest / Run pytest (ubuntu-latest, Python 3.11)` +- `CI – Pytest / Run pytest (ubuntu-latest, Python 3.12)` +- `CI – Pytest / Run pytest (macos-latest, Python 3.11)` +- `CI – Pytest / Run pytest (macos-latest, Python 3.12)` +- `CI – Pytest / Run pytest (windows-latest, Python 3.11)` +- `CI – Pytest / Run pytest (windows-latest, Python 3.12)` - `Python package security PR checks / Vulnerability scan (packages/runtime)` - `Python package security PR checks / Vulnerability scan (packages/connectors/http_generic)` - `Python package security PR checks / Vulnerability scan (packages/connectors/stripe)` @@ -54,7 +68,34 @@ Configure branch protection so pull requests cannot merge unless all required ch - The PR/push gate (`security-pr.yml`) runs `pip-audit` with no `--fail-on` threshold, so it **blocks on any vulnerability**. The release workflow (`publish.yml`) uses `pip-audit --fail-on HIGH` as defense in depth. - Scheduled scans catch newly disclosed CVEs even when code does not change. -**Monorepo install note:** Connector packages under `packages/connectors/*` declare `node-wire-runtime>=0.1.0` as a normal PyPI dependency name. The security workflow installs `packages/runtime` from the checkout **together with** each matrix package (`pip install packages/runtime ""`) so `pip` can resolve `node-wire-runtime` without requiring a published wheel on PyPI. Locally, mirror that when auditing a single connector: `pip install packages/runtime packages/connectors/`. +**Monorepo install note:** Connector packages under `packages/connectors/*` declare `node-wire-runtime>=1.0.0` as a normal PyPI dependency name. The security workflow installs `packages/runtime` from the checkout **together with** each matrix package (`pip install packages/runtime ""`) so `pip` can resolve `node-wire-runtime` without requiring a published wheel on PyPI. Locally, mirror that when auditing a single connector: `pip install packages/runtime packages/connectors/`. + +## Secret scanning + +Workflow: `.github/workflows/secret-scan.yml` (Gitleaks). + +Policy: + +- Scan on every PR and push to `main`/`master`, plus a weekly scheduled run. +- Full repository history is included (`fetch-depth: 0`). +- Findings fail the workflow; remediate by rotating exposed credentials and + removing secrets from the codebase (never commit live secrets). + +### Run locally + +Install [Gitleaks](https://github.com/gitleaks/gitleaks) (e.g. `brew install gitleaks` +on macOS), then from the repository root: + +```bash +# Working tree (staged + unstaged changes vs HEAD) +gitleaks detect --source . --redact --verbose + +# Full git history (matches CI intent) +gitleaks detect --source . --redact --verbose --log-opts="--all" +``` + +If GitHub Advanced Security secret scanning is enabled at the organization level, +treat it as defense in depth; the in-repo workflow provides auditable CI evidence. ## Run checks locally @@ -133,6 +174,6 @@ CycloneDX SBOM (`sbom.json`) is generated by: - Security scan runs on every PR: enforced by `quality-gates.yml` (Bandit) and `codeql.yml` (CodeQL). - Builds fail on high-severity Bandit findings: Bandit gate in CI. - Static analysis visible in GitHub Security tab: CodeQL upload from CI. -- Tests run on every PR: enforced by `pytest.yml` (3.11 + 3.12 matrix). +- Tests run on every PR: enforced by `pytest.yml` (Linux/macOS/Windows × Python 3.11/3.12). - Developers run checks locally: documented commands and pre-commit (Bandit). - Config version-controlled: `pyproject.toml`, `.pre-commit-config.yaml`, workflow files. diff --git a/docs/release-rollback.md b/docs/release-rollback.md new file mode 100644 index 0000000..40bf449 --- /dev/null +++ b/docs/release-rollback.md @@ -0,0 +1,144 @@ + + +# Release Rollback & Unpublish Runbook + +This runbook describes how maintainers respond when a bad Node Wire release +reaches PyPI or GitHub. Read it together with +[packaging.md](packaging.md) and [versioning.md](versioning.md). + +## PyPI constraints (read first) + +- **Published versions cannot be overwritten.** Uploading the same version again + will fail. +- **Prefer yanking** over deleting a release. Yanking hides a version from the + default `pip install` resolver while leaving it available for explicit pins. +- **Deletion is exceptional** — use only for legal, trademark, or severe + security cases, and coordinate with PyPI support if needed. +- **Sigstore attestations** published with a release remain on the public + transparency log; yanking does not revoke them. + +## When to use this runbook + +| Scenario | Typical response | +|---|---| +| Broken wheel / install failure | Yank + patch release | +| Critical security vulnerability in published code | Yank + advisory + patch/hotfix | +| Wrong version tagged (metadata only) | Yank if published; fix tag/docs | +| Secrets committed and released | Yank + rotate secrets + history remediation | +| Non-security functional regression | Patch release; yank only if install is unsafe | + +## Roles + +- **Release maintainer** — executes PyPI yank, publishes corrective release, + updates GitHub release/tag state. +- **Security contact** — triages severity, coordinates advisory if needed + ([SECURITY.md](../SECURITY.md)). +- **Comms** — notifies users via GitHub Discussions, release notes, or advisory. + +## Step 1 — Triage and freeze + +1. Confirm which **package(s)** and **version(s)** are affected (nine publishable + packages; see [packaging.md](packaging.md#package-inventory)). +2. Record the Git tag (`vX.Y.Z`), commit SHA, and PyPI project name(s). +3. **Stop further publishes** of the affected version until root cause is known. +4. Open an internal incident thread (issue or private channel) with: + - impact (install broken, data leak, CVE, etc.) + - affected versions and platforms + - whether users who already installed must take action + +## Step 2 — Yank on PyPI + +Yanking requires a PyPI account with maintainer rights on the project. + +```bash +# List current files for a project (optional) +pip index versions node-wire-runtime + +# Yank via PyPI web UI (recommended): +# https://pypi.org/manage/project//releases/ +# → select version → "Yank" → provide reason (shown to users) + +# Or via twine (if configured): +twine yank node-wire-runtime 1.0.0 --reason "Install regression; use 1.0.1 instead" +``` + +Repeat for every affected package (runtime and any connector wheels published +at the bad version). + +**Yank reason template:** + +> Do not use. Install <fixed-version> instead. See <GitHub release or advisory URL>. + +## Step 3 — Publish a corrective release + +1. Fix the defect on `main` (or a release branch) with tests. +2. Bump **PATCH** per [SemVer](versioning.md) (e.g. `1.0.0` → `1.0.1`). +3. Update [CHANGELOG.md](../CHANGELOG.md) with the fix and yank notice. +4. Run the local pre-publish checklist in [packaging.md](packaging.md#pre-pypi-local-validation-checklist). +5. Dispatch `.github/workflows/publish.yml` for each affected `package_path` + with the new `version` input. + +## Step 4 — GitHub release and tags + +| Situation | Action | +|---|---| +| Tag points at bad commit, not widely used | Delete remote tag; retag fixed commit; edit GitHub Release | +| Tag already referenced externally | **Do not** rewrite history; publish new tag `vX.Y.Z+1` and mark old release as pre-release with warning | +| GitHub Release notes wrong | Edit release description; link to corrective version | + +```bash +# Delete a remote tag only when safe (no external references) +git push origin :refs/tags/v1.0.0 +git tag -a v1.0.1 -m "Release 1.0.1" +git push origin v1.0.1 +``` + +## Step 5 — Communicate + +Minimum user-facing notice (GitHub Release, Discussion, or advisory): + +- Affected package names and version(s) +- Whether the version was yanked +- Fixed version to install: `pip install node-wire-runtime==X.Y.Z` +- Any manual steps (config change, secret rotation, data fix) +- Link to CHANGELOG entry + +For security issues, follow coordinated disclosure in [SECURITY.md](../SECURITY.md) +before public announcement. + +## Step 6 — Post-incident + +- [ ] Root cause documented (issue or post-mortem) +- [ ] CI gap closed if the defect should have been caught pre-publish +- [ ] [CHANGELOG.md](../CHANGELOG.md) and [docs/troubleshooting.md](troubleshooting.md) updated if user-visible +- [ ] Branch protection / required checks reviewed +- [ ] If secrets were exposed: rotate credentials, run secret scan (see + [quality-security-gates.md](quality-security-gates.md#secret-scanning)), + and consider `git filter-repo` only with legal/security approval + +## Docker images + +Demo MCP images built from yanked wheels should be rebuilt and re-tagged with the +fixed version. Document the new tag in release notes; do not delete public images +without a communications plan. + +## Quick reference + +```bash +# Verify what pip would install after yank +pip install "node-wire-runtime>=1.0,<1.1" --dry-run + +# Install explicit fixed version +pip install node-wire-runtime==1.0.1 +``` + +## Related docs + +- [Packaging & Publishing](packaging.md) — publish workflow and pre-release checks +- [Versioning policy](versioning.md) — when to bump MAJOR/MINOR/PATCH +- [Security Policy](../SECURITY.md) — vulnerability reporting and advisories +- [Quality & security gates](quality-security-gates.md) — CI checks and scans From b53cac96a5d9627a7dc321739ca06668ecdc1cce Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:57:07 -0700 Subject: [PATCH 09/20] Removed debug logs --- src/agents/toolhive.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/agents/toolhive.py b/src/agents/toolhive.py index bf21b89..de8cb03 100644 --- a/src/agents/toolhive.py +++ b/src/agents/toolhive.py @@ -633,8 +633,6 @@ async def run(self, task: str) -> AgentRunResult: next_token = pagination_meta.get("next_page_token") if next_token: - print("\n=== PAGINATION TOKEN DETECTED ===", file=sys.stderr) - # Add pagination info to tool result for LLM to see pagination_info = ( f"\n\n[PAGINATION INFO]\n" @@ -644,17 +642,9 @@ async def run(self, task: str) -> AgentRunResult: f"To get next page, call the same tool with page_token='{next_token}'" ) tool_result_str += pagination_info - print("=== ADDED PAGINATION INFO TO RESULT ===", file=sys.stderr) - else: - print("\n=== NO PAGINATION TOKEN FOUND ===", file=sys.stderr) + logger.debug("Added pagination info for tool %s", tc.name) except (json.JSONDecodeError, KeyError) as e: - print(f"Error parsing pagination metadata: {e}", file=sys.stderr) - - print( - "=================================================\n", - file=sys.stderr, - flush=True, - ) + logger.debug("Could not parse pagination metadata: %s", e) except Exception as exc: tool_result_str = f"ERROR: {exc}" From e3ca10cc3cd752ba255bde1391ecc42be5433d20 Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:16:38 -0700 Subject: [PATCH 10/20] Fixed DCO for dependabot --- .github/workflows/dco.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml index 33b242c..1817fbe 100644 --- a/.github/workflows/dco.yml +++ b/.github/workflows/dco.yml @@ -16,6 +16,12 @@ permissions: jobs: dco: name: DCO sign-off check + # Bots (Dependabot, GitHub Actions, Renovate, etc.) cannot sign off commits. + # Skip the check entirely for bot-authored PRs. + if: | + github.actor != 'dependabot[bot]' && + github.actor != 'github-actions[bot]' && + github.actor != 'renovate[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -30,6 +36,10 @@ jobs: run: | set -euo pipefail + # Bot email domains used by GitHub-managed bots. Commits authored by + # these are skipped — bots cannot provide a DCO sign-off. + BOT_PATTERN="noreply.github.com" + commits="$(git rev-list --no-merges "${BASE_SHA}..${HEAD_SHA}")" if [ -z "${commits}" ]; then echo "No non-merge commits to check." @@ -40,6 +50,13 @@ jobs: for sha in ${commits}; do subject="$(git show -s --format='%s' "${sha}")" author="$(git show -s --format='%an <%ae>' "${sha}")" + author_email="$(git show -s --format='%ae' "${sha}")" + + if echo "${author_email}" | grep -qF "${BOT_PATTERN}"; then + echo "SKIP ${sha:0:12} ${subject} (bot commit)" + continue + fi + if git show -s --format='%B' "${sha}" \ | grep -iqF "Signed-off-by: ${author}"; then echo "PASS ${sha:0:12} ${subject}" From ddfee6d1d3835aaf56376e6dba9429dce6c41c0f Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:17:34 -0700 Subject: [PATCH 11/20] src/node_wire_runtime/observability.py --- src/node_wire_runtime/observability.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/node_wire_runtime/observability.py b/src/node_wire_runtime/observability.py index 2a827f6..2f70440 100644 --- a/src/node_wire_runtime/observability.py +++ b/src/node_wire_runtime/observability.py @@ -155,14 +155,16 @@ def init_observability(app_name: str = "node_wire") -> None: # Initialize Traceloop/OpenLLMetry in metadata-only mode. Advanced AI features # (prompt logging, workflows, tools) are intentionally deferred. - try: - from traceloop.sdk import Traceloop - - Traceloop.init( - app_name=app_name, - ) - except Exception as exc: # pragma: no cover - defensive; should not fail app startup - logger.warning("Failed to initialize Traceloop/OpenLLMetry: %s", exc) + # Skip silently when no API key is configured — Traceloop is optional. + if os.environ.get("TRACELOOP_API_KEY"): + try: + from traceloop.sdk import Traceloop + + Traceloop.init( + app_name=app_name, + ) + except Exception as exc: # pragma: no cover - defensive; should not fail app startup + logger.warning("Failed to initialize Traceloop/OpenLLMetry: %s", exc) _INITIALIZED = True logger.info("Observability initialized for app %s", app_name) From ef9830e921275b85430692c6f56dd4cb80a0bb6e Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:53:05 -0700 Subject: [PATCH 12/20] Fix TRACELOOP_API_KEY test --- tests/test_observability.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_observability.py b/tests/test_observability.py index 246d127..f74feea 100644 --- a/tests/test_observability.py +++ b/tests/test_observability.py @@ -124,7 +124,9 @@ def test_init_observability_installs_sanitizing_log_filter() -> None: def test_init_observability_traceloop_failure_does_not_raise( caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setenv("TRACELOOP_API_KEY", "test-key") with _observability_test_patches() as (_s, _l, mock_tl): mock_tl.init = MagicMock(side_effect=RuntimeError("traceloop unavailable")) with caplog.at_level(logging.WARNING, logger="runtime.observability"): From 01f409bf7e0950122b66f74bf4779b32b00dab68 Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:56:55 -0700 Subject: [PATCH 13/20] Fix linting --- tests/test_connectors_io.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_connectors_io.py b/tests/test_connectors_io.py index 93b49a8..3da47f3 100644 --- a/tests/test_connectors_io.py +++ b/tests/test_connectors_io.py @@ -394,7 +394,10 @@ def test_sanitize_url_for_log_ipv6_and_invalid() -> None: from node_wire_http_generic.logic import _sanitize_url_for_log - assert _sanitize_url_for_log("https://[2001:db8::1]:8443/path?q=1") == "https://[2001:db8::1]:8443/path" + assert ( + _sanitize_url_for_log("https://[2001:db8::1]:8443/path?q=1") + == "https://[2001:db8::1]:8443/path" + ) with patch("node_wire_http_generic.logic.urlsplit", side_effect=ValueError("bad url")): assert _sanitize_url_for_log("not-a-url") == "" From 2a68d475c5153a36d600ae45b6d9c0500b83ec8a Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:55:05 -0700 Subject: [PATCH 14/20] Move from gitleaks action to gitleaks CLI --- .github/workflows/secret-scan.yml | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index a222bf2..c70e025 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -29,7 +29,24 @@ jobs: with: fetch-depth: 0 + - name: Cache Gitleaks binary + id: cache-gitleaks + uses: actions/cache@v4 + with: + path: ./gitleaks + key: gitleaks-v8.27.2-linux-x64 + + - name: Download Gitleaks + if: steps.cache-gitleaks.outputs.cache-hit != 'true' + run: | + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.27.2/gitleaks_8.27.2_linux_x64.tar.gz \ + | tar -xz gitleaks + chmod +x gitleaks + - name: Run Gitleaks - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [[ ! -x ./gitleaks ]]; then + echo "::error::Gitleaks binary missing or not executable — cache may be corrupt" + exit 1 + fi + ./gitleaks detect --source . --redact --exit-code 1 From 39611a4bd8be7036b3fc3072e72a0f2e66671168 Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:01:59 -0700 Subject: [PATCH 15/20] Ignore dependabot for internal packages --- .github/dependabot.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6868561..3b3694c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,6 +13,10 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 10 + ignore: + # Internal monorepo dependency; connector packages are versioned together + # during releases, not independently by Dependabot. + - dependency-name: "node-wire-runtime" groups: python-dependencies: patterns: ["*"] From d1096607a9efda1ef3b3f018d049b1fbb110e0c2 Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:36:14 -0700 Subject: [PATCH 16/20] Update packages to create a release version and package --- .github/workflows/github-release.yml | 191 ++++++++++++++ .github/workflows/publish.yml | 189 ++++++++++---- docs/code-quality-compliance.md | 2 +- docs/packaging.md | 76 +++++- docs/quality-security-gates.md | 358 +++++++++++++-------------- docs/release-rollback.md | 2 +- 6 files changed, 579 insertions(+), 239 deletions(-) create mode 100644 .github/workflows/github-release.yml diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml new file mode 100644 index 0000000..ff4589e --- /dev/null +++ b/.github/workflows/github-release.yml @@ -0,0 +1,191 @@ +## +## SPDX-FileCopyrightText: 2026 AOT Technologies +## SPDX-License-Identifier: Apache-2.0 +## + +name: GitHub Release + +# Manual trigger after pushing a release tag. +# Go to Actions → "GitHub Release" → Run workflow with the target version. +on: + workflow_dispatch: + inputs: + version: + description: "Semver version to release, without leading v (for example, 1.0.0)" + required: true + type: string + +permissions: + contents: write + +env: + CYCLONEDX_BOM_VERSION: "4.6.1" + +jobs: + github-release: + name: Create GitHub release + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: refs/tags/v${{ inputs.version }} + fetch-depth: 0 + + - name: Resolve release version + shell: python + run: | + import os + import re + import sys + + version = "${{ inputs.version }}".strip() + tag = f"v{version}" + + if not re.fullmatch(r"\d+\.\d+\.\d+", version): + print(f"ERROR: {version!r} is not a MAJOR.MINOR.PATCH version", file=sys.stderr) + sys.exit(1) + + with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as env: + env.write(f"RELEASE_VERSION={version}\n") + env.write(f"RELEASE_TAG={tag}\n") + + print(f"Preparing release {tag}") + + - name: Verify tag exists + run: git rev-parse --verify "refs/tags/${RELEASE_TAG}" + + - name: Set up Python + uses: actions/setup-python@v5.3.0 + with: + python-version: "3.11" + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock + + - name: Validate versions and changelog + shell: python + run: | + import os + import pathlib + import re + import sys + import tomllib + + version = os.environ["RELEASE_VERSION"] + root = pathlib.Path(".") + pyprojects = [root / "pyproject.toml", *sorted(root.glob("packages/**/pyproject.toml"))] + + mismatches = [] + for path in pyprojects: + data = tomllib.loads(path.read_text(encoding="utf-8")) + actual = data.get("project", {}).get("version") + if actual != version: + mismatches.append(f"{path}: expected {version}, found {actual}") + + if mismatches: + print("ERROR: package versions do not match release tag", file=sys.stderr) + for mismatch in mismatches: + print(f" {mismatch}", file=sys.stderr) + sys.exit(1) + + changelog = pathlib.Path("CHANGELOG.md").read_text(encoding="utf-8") + heading = re.compile( + rf"^## \[{re.escape(version)}\] - \d{{4}}-\d{{2}}-\d{{2}}\s*$", + re.MULTILINE, + ) + match = heading.search(changelog) + if not match: + print(f"ERROR: CHANGELOG.md is missing a dated [{version}] section", file=sys.stderr) + sys.exit(1) + + next_heading = re.search(r"^## \[", changelog[match.end():], re.MULTILINE) + end = match.end() + next_heading.start() if next_heading else len(changelog) + release_body = changelog[match.start():end].strip() + + link_pattern = rf"^\[{re.escape(version)}\]: .+/releases/tag/v{re.escape(version)}\s*$" + if not re.search(link_pattern, changelog, re.MULTILINE): + print(f"ERROR: CHANGELOG.md is missing the [{version}] release link", file=sys.stderr) + sys.exit(1) + + pathlib.Path("release-notes.md").write_text(release_body + "\n", encoding="utf-8") + print(f"PASS: versions and changelog are ready for {version}") + + - name: Install release dependencies + run: | + uv sync --frozen --all-extras --no-dev + uv pip install "cyclonedx-bom==${{ env.CYCLONEDX_BOM_VERSION }}" + + - name: Generate SBOM + run: | + uv run cyclonedx-py environment -o sbom.json + echo "SBOM generated: sbom.json" + + - name: Create release manifest + shell: python + run: | + import datetime + import hashlib + import os + import pathlib + + version = os.environ["RELEASE_VERSION"] + tag = os.environ["RELEASE_TAG"] + sha = "${{ github.sha }}" + sbom_path = pathlib.Path("sbom.json") + sbom_sha = hashlib.sha256(sbom_path.read_bytes()).hexdigest() + created = datetime.datetime.now(datetime.UTC).replace(microsecond=0).isoformat() + + package_paths = [ + "packages/runtime", + "packages/connectors/http_generic", + "packages/connectors/stripe", + "packages/connectors/smtp", + "packages/connectors/google_drive", + "packages/connectors/fhir_cerner", + "packages/connectors/fhir_epic", + "packages/connectors/salesforce", + "packages/connectors/slack", + ] + + lines = [ + f"Release: {tag}", + f"Version: {version}", + f"Commit: {sha}", + f"Created: {created}", + "Changelog: CHANGELOG.md", + "SBOM: sbom.json", + f"SBOM-SHA256: {sbom_sha}", + "", + "Publishable packages (dispatch publish.yml per package with this tag):", + ] + lines.extend(f" - {path} @ {version}" for path in package_paths) + lines.append("") + + pathlib.Path("release-manifest.txt").write_text("\n".join(lines), encoding="utf-8") + + - name: Upload release artifacts to workflow run + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: github-release-artifacts + path: | + release-notes.md + release-manifest.txt + sbom.json + if-no-files-found: error + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${RELEASE_TAG}" \ + --verify-tag \ + --title "${RELEASE_TAG}" \ + --notes-file release-notes.md \ + sbom.json \ + release-manifest.txt diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 05c9a90..39000f8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,50 +7,75 @@ # yamllint disable rule:line-length name: Publish Node Wire package -# Manual trigger: go to Actions → "Publish Node Wire package" → Run workflow. +# Manual trigger after a GitHub Release exists for the tag. # package_path must match the allowlist below (prevents confused-deputy path abuse). # # Examples: +# tag: v1.0.0 # package_path: packages/runtime # package_path: packages/connectors/fhir_epic -# package_path: packages/connectors/google_drive on: workflow_dispatch: inputs: + tag: + description: "Release tag to publish from (e.g. v1.0.0)" + required: true + type: string package_path: description: | Relative path to the package directory (must be allowlisted). Examples: packages/runtime | packages/connectors/fhir_epic required: true type: string - version: - description: "Semver version to publish (e.g. 0.2.0)" - required: true - type: string env: PIP_AUDIT_VERSION: "2.7.3" - CYCLONEDX_BOM_VERSION: "4.6.1" jobs: # ───────────────────────────────────────────────────────────────────────────── - # Build a binary wheel for each platform (Linux / macOS / Windows). - # cibuildwheel compiles Cython extensions and produces manylinux / macosx / - # win_amd64 wheels. The NoPyBuild override in setup.py ensures .py source - # files are excluded from all wheels. + # Verify prerequisites before building wheels. # ───────────────────────────────────────────────────────────────────────────── - build-wheels: - name: Build (${{ matrix.os }}) - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} + prerequisites: + name: Verify prerequisites + runs-on: ubuntu-latest permissions: contents: read + outputs: + release_version: ${{ steps.resolve.outputs.release_version }} + release_tag: ${{ steps.resolve.outputs.release_tag }} + package_path: ${{ steps.validate.outputs.package_path }} + artifact_slug: ${{ steps.validate.outputs.artifact_slug }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: refs/tags/${{ inputs.tag }} + fetch-depth: 0 + + - name: Resolve release version from tag + id: resolve + shell: python + run: | + import os + import re + import sys + + tag = "${{ inputs.tag }}".strip() + version = tag[1:] if tag.startswith("v") else tag + + if not re.fullmatch(r"\d+\.\d+\.\d+", version): + print(f"ERROR: {version!r} is not a MAJOR.MINOR.PATCH version", file=sys.stderr) + sys.exit(1) + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as out: + out.write(f"release_version={version}\n") + out.write(f"release_tag={tag}\n") + + print(f"Publishing from tag {tag} (version {version})") + - name: Validate package path (allowlist) + id: validate shell: python run: | import os @@ -58,7 +83,6 @@ jobs: raw = "${{ inputs.package_path }}".strip().replace("\\", "/") norm = os.path.normpath(raw).replace("\\", "/") - # Reject traversal / absolute paths if norm.startswith("..") or os.path.isabs(raw): print("ERROR: invalid package_path", file=sys.stderr) sys.exit(1) @@ -77,9 +101,81 @@ jobs: print(f"ERROR: package_path {norm!r} is not allowlisted.", file=sys.stderr) print("Allowed:", sorted(allowed), file=sys.stderr) sys.exit(1) + + artifact_slug = norm.replace("/", "-") + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as out: + out.write(f"package_path={norm}\n") + out.write(f"artifact_slug={artifact_slug}\n") + print(f"PASS: package_path {norm!r} is allowlisted") + - name: Verify package version and changelog + shell: python + run: | + import pathlib + import re + import sys + import tomllib + + version = "${{ steps.resolve.outputs.release_version }}" + package_path = pathlib.Path("${{ steps.validate.outputs.package_path }}") + + data = tomllib.loads((package_path / "pyproject.toml").read_text(encoding="utf-8")) + actual = data.get("project", {}).get("version") + if actual != version: + print( + f"ERROR: {package_path}/pyproject.toml version {actual!r} " + f"does not match tag version {version!r}", + file=sys.stderr, + ) + sys.exit(1) + + changelog = pathlib.Path("CHANGELOG.md").read_text(encoding="utf-8") + heading = re.compile( + rf"^## \[{re.escape(version)}\] - \d{{4}}-\d{{2}}-\d{{2}}\s*$", + re.MULTILINE, + ) + if not heading.search(changelog): + print(f"ERROR: CHANGELOG.md is missing a dated [{version}] section", file=sys.stderr) + sys.exit(1) + + link_pattern = rf"^\[{re.escape(version)}\]: .+/releases/tag/v{re.escape(version)}\s*$" + if not re.search(link_pattern, changelog, re.MULTILINE): + print(f"ERROR: CHANGELOG.md is missing the [{version}] release link", file=sys.stderr) + sys.exit(1) + + print(f"PASS: package version and changelog match {version}") + + - name: Verify GitHub Release exists + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release view "${{ steps.resolve.outputs.release_tag }}" \ + --json tagName \ + --jq '.tagName' + + # ───────────────────────────────────────────────────────────────────────────── + # Build a binary wheel for each platform (Linux / macOS / Windows). + # cibuildwheel compiles Cython extensions and produces manylinux / macosx / + # win_amd64 wheels. The NoPyBuild override in setup.py ensures .py source + # files are excluded from all wheels. + # ───────────────────────────────────────────────────────────────────────────── + build-wheels: + name: Build (${{ matrix.os }}) + needs: prerequisites + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + permissions: + contents: read + + steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: refs/tags/${{ needs.prerequisites.outputs.release_tag }} - name: Set up Python uses: actions/setup-python@v5.3.0 @@ -91,21 +187,19 @@ jobs: - name: Build platform wheel(s) run: | - cd "${{ inputs.package_path }}" + cd "${{ needs.prerequisites.outputs.package_path }}" python -m cibuildwheel --output-dir dist env: - # Build only Python 3.11+ wheels (matches requires-python in pyproject.toml) CIBW_BUILD: "cp311-* cp312-*" - # Skip 32-bit targets and PyPy — not supported CIBW_SKIP: "*-win32 *-manylinux_i686 pp*" - # ── Security gate: verify no .py source files leaked into any wheel ────── - name: Verify binary-only wheel (no .py source) shell: python run: | import glob, sys, zipfile - wheels = glob.glob("${{ inputs.package_path }}/dist/*.whl") + package_path = "${{ needs.prerequisites.outputs.package_path }}" + wheels = glob.glob(f"{package_path}/dist/*.whl") if not wheels: print("ERROR: No wheels produced", file=sys.stderr) sys.exit(1) @@ -130,8 +224,9 @@ jobs: - name: Record wheel SHA256 (artifact integrity) shell: python run: | - import glob, hashlib, pathlib, sys - dist = pathlib.Path("${{ inputs.package_path }}") / "dist" + import hashlib, pathlib, sys + + dist = pathlib.Path("${{ needs.prerequisites.outputs.package_path }}") / "dist" wheels = sorted(dist.glob("*.whl")) if not wheels: print("ERROR: no wheels to hash", file=sys.stderr) @@ -147,8 +242,8 @@ jobs: - name: Upload wheel artifacts uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: - name: wheels-${{ matrix.os }} - path: ${{ inputs.package_path }}/dist/*.whl + name: wheels-${{ matrix.os }}-${{ needs.prerequisites.outputs.release_tag }}-${{ needs.prerequisites.outputs.artifact_slug }} + path: ${{ needs.prerequisites.outputs.package_path }}/dist/* if-no-files-found: error # ───────────────────────────────────────────────────────────────────────────── @@ -159,14 +254,16 @@ jobs: # ───────────────────────────────────────────────────────────────────────────── publish: name: Publish to PyPI - needs: build-wheels + needs: [prerequisites, build-wheels] runs-on: ubuntu-latest permissions: - id-token: write # Required for Trusted Publisher OIDC + id-token: write contents: read steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: refs/tags/${{ needs.prerequisites.outputs.release_tag }} - name: Set up Python uses: actions/setup-python@v5.3.0 @@ -176,7 +273,9 @@ jobs: - name: Download all wheel artifacts uses: actions/download-artifact@v4 with: + pattern: wheels-*-${{ needs.prerequisites.outputs.release_tag }}-${{ needs.prerequisites.outputs.artifact_slug }} path: dist-all + merge-multiple: true - name: Flatten into dist/ directory run: | @@ -186,18 +285,28 @@ jobs: ls dist/ sha256sum dist/*.whl | tee dist/sha256sums.txt - - name: Validate wheel version matches input + - name: Upload wheel checksums + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: wheel-checksums-${{ needs.prerequisites.outputs.release_tag }}-${{ needs.prerequisites.outputs.artifact_slug }} + path: dist/sha256sums.txt + if-no-files-found: error + + - name: Validate wheel version matches tag shell: python run: | import glob, sys - expected = "${{ inputs.version }}" + expected = "${{ needs.prerequisites.outputs.release_version }}" wheels = glob.glob("dist/*.whl") if not wheels: print("ERROR: No wheels found for publish", file=sys.stderr) sys.exit(1) - bad = [w for w in wheels if f"-{expected}-" not in w and f"-{expected.replace('.', '_')}-" not in w] + bad = [ + w for w in wheels + if f"-{expected}-" not in w and f"-{expected.replace('.', '_')}-" not in w + ] if bad: print(f"ERROR: Version mismatch. Expected {expected!r} in filename.", file=sys.stderr) for w in bad: @@ -214,22 +323,8 @@ jobs: pip install "pip-audit==${{ env.PIP_AUDIT_VERSION }}" pip-audit --fail-on HIGH - - name: Generate SBOM - run: | - pip install "cyclonedx-bom==${{ env.CYCLONEDX_BOM_VERSION }}" - cyclonedx-py environment -o sbom.json - echo "SBOM generated: sbom.json" - - - name: Upload SBOM as release artifact - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 - with: - name: sbom-${{ inputs.version }} - path: sbom.json - - name: Publish to PyPI (Trusted Publisher / OIDC + Sigstore attestations) uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 with: packages-dir: dist/ - # attestations: true generates a Sigstore attestation automatically. - # Clients can verify with: pip download && python -m pypi_attestation_viewer attestations: true diff --git a/docs/code-quality-compliance.md b/docs/code-quality-compliance.md index a1d4e65..74f023e 100644 --- a/docs/code-quality-compliance.md +++ b/docs/code-quality-compliance.md @@ -76,7 +76,7 @@ bash scripts/add-license-headers.sh 3. Verify freshness: `uv lock --check` 4. Commit both `pyproject.toml` and `uv.lock`. -`DEPENDENCIES.md` is a human-readable license inventory (from `pip-licenses`). `sbom.json` is a CycloneDX SBOM generated by the compliance script (`scripts/run-compliance-checks.sh`) and at release time via `.github/workflows/publish.yml`. +`DEPENDENCIES.md` is a human-readable license inventory (from `pip-licenses`). `sbom.json` is a CycloneDX SBOM generated by the compliance script (`scripts/run-compliance-checks.sh`) and at release time via `.github/workflows/github-release.yml`. ## Dependency inventory and compliance diff --git a/docs/packaging.md b/docs/packaging.md index c1ea690..d5486fc 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -170,25 +170,72 @@ pip install "node-wire-runtime[gcp]" # google-cloud-secret-manager --- -## CI publish flow (Trusted Publisher) +## Release process (tag-first) + +Releases are **tag-driven**. Create and push a SemVer tag first; the GitHub Release +workflow validates the tag and creates the release. Package publishing is a separate +manual step per package, bound to that tag. + +### Step 1 — Prepare the release + +1. Bump version in the root `pyproject.toml` and all nine package `pyproject.toml` files. +2. Add a dated `CHANGELOG.md` section and release link for the target version. +3. Merge to `main` and confirm required CI checks are green. + +### Step 2 — Create the GitHub Release + +```bash +git tag -a v1.0.0 -m "Release 1.0.0" +git push origin v1.0.0 +``` + +Then dispatch **GitHub Release** in Actions with `version` set to `1.0.0` (no leading `v`). + +**Workflow:** `.github/workflows/github-release.yml` — manual `workflow_dispatch` +after the tag has been pushed. + +The workflow: -**Workflow:** `.github/workflows/publish.yml` — manual `workflow_dispatch`. +1. Validates all package versions match the tag. +2. Verifies `CHANGELOG.md` has the matching section and release link. +3. Generates `sbom.json` (release-level SBOM). +4. Creates `release-manifest.txt` listing all nine publishable package paths. +5. Creates the GitHub Release with changelog notes, SBOM, and manifest attached. + +### Step 3 — Publish packages to PyPI + +After the GitHub Release exists, dispatch `.github/workflows/publish.yml` **once per +package** (nine times for a full release). **Required inputs:** | Input | Example | Notes | |---|---|---| -| `package_path` | `packages/connectors/stripe` | Must match an entry in the workflow's allowlist | -| `version` | `0.1.0` | Must match `[project].version` in the package's `pyproject.toml` | +| `tag` | `v1.0.0` | Must match an existing release tag | +| `package_path` | `packages/connectors/stripe` | Must match the workflow allowlist | + +**Prerequisites checked before build:** + +- Tag resolves to a valid SemVer version. +- `package_path` is allowlisted. +- Package `pyproject.toml` version matches the tag. +- `CHANGELOG.md` contains the matching release section/link. +- A GitHub Release exists for the tag. **Pipeline steps:** -1. Validate `package_path` against allowlist (prevents path traversal) -2. Matrix-build wheels on Ubuntu, macOS, Windows via `cibuildwheel` (Python 3.11, 3.12; Linux manylinux + aarch64, macOS x86_64 + arm64, Windows amd64) -3. Post-build gate: verify zero `.py` files per wheel; record SHA256 checksums -4. Merge artifacts; `pip-audit --fail-on HIGH` CVE gate -5. Generate SBOM via `cyclonedx-py` -6. Publish to PyPI via OIDC Trusted Publisher with Sigstore attestations (all action SHAs pinned for immutability) +1. Matrix-build wheels on Ubuntu, macOS, Windows via `cibuildwheel` (Python 3.11, 3.12) +2. Post-build gate: verify zero `.py` files per wheel; record SHA256 checksums +3. Merge artifacts; `pip-audit --fail-on HIGH` CVE gate +4. Publish to PyPI via OIDC Trusted Publisher with Sigstore attestations + +> **Note:** The release-level SBOM is attached to the GitHub Release (step 2). +> Package publish produces PyPI Sigstore attestations per wheel; it does not +> generate a separate SBOM. + +> **PyPI Trusted Publisher:** The workflow file is kept as `publish.yml` and the +> workflow name as `Publish Node Wire package` so existing PyPI publisher +> configuration continues to work. If a published release must be withdrawn or replaced, follow [release-rollback.md](release-rollback.md) (PyPI yank, corrective patch release, @@ -196,6 +243,13 @@ and GitHub tag/release handling). --- +## CI publish flow (Trusted Publisher) + +See [Release process (tag-first)](#release-process-tag-first) above for the full +end-to-end flow. The package publish workflow is `.github/workflows/publish.yml`. + +--- + ## Docker demo images The `docker/*/Dockerfile` images are **demonstration templates** for packaging a single connector as a standalone MCP server. They are not production orchestration artefacts. @@ -226,4 +280,4 @@ Run these gates before triggering the CI publish workflow (default `build-packag - [ ] `auto_register()` loads expected connectors - [ ] `pytest tests/test_connector_registry.py tests/test_connectors_basic.py` passes - [ ] Wheel SHA256 checksums recorded and match expected values -- [ ] `package_path` and `version` inputs match the allowlist and `pyproject.toml` version before dispatching the workflow +- [ ] `package_path` and `tag` inputs match the allowlist and an existing release tag before dispatching the workflow diff --git a/docs/quality-security-gates.md b/docs/quality-security-gates.md index c5f1d04..7fbd628 100644 --- a/docs/quality-security-gates.md +++ b/docs/quality-security-gates.md @@ -1,179 +1,179 @@ -# SPDX-FileCopyrightText: 2026 AOT Technologies -# -# SPDX-License-Identifier: Apache-2.0 - -# Quality and security gates - -This document defines how Node Wire enforces security scanning in CI. - -This repository enforces security gates at both PR time and publish time. - -## CI quality gates - -Workflow: `.github/workflows/quality-gates.yml` - -Runs on every pull request and on pushes to `main`/`master`. - -Required jobs: - -- `bandit`: writes `bandit-report.json` (with `--exit-zero` so low/medium findings do not fail the job before the gate), prints a log summary, uploads the artifact, then fails only on **high**-severity findings in the enforce step. - -Workflow: `.github/workflows/codeql.yml` - -Runs GitHub CodeQL static analysis for Python on pull requests, pushes to `main`/`master`, and weekly (Mondays). No repository secrets are required. - -Workflow: `.github/workflows/pytest.yml` - -Runs the full test suite on **Linux, macOS, and Windows** (Python 3.11 and 3.12 -matrix) with coverage on every pull request and push to `main`/`master`. -Playground integration tests remain manual (`workflow_dispatch`) on Ubuntu only. - -Workflow: `.github/workflows/lint.yml` also runs `lockfile-check` (`uv lock --check`) to fail PRs when `pyproject.toml` changes without an updated `uv.lock`. - -Workflow: `.github/workflows/secret-scan.yml` - -Runs [Gitleaks](https://github.com/gitleaks/gitleaks) on pull requests, pushes to -`main`/`master`, weekly (Mondays), and on manual dispatch. The workflow checks -out **full git history** (`fetch-depth: 0`) so secrets in past commits are -scanned, not only the working tree. - -Required checks to add in branch protection: - -- `Lint and Type Check / Lockfile freshness` -- `Quality gates / Bandit security scan` -- `CodeQL / Analyze (Python)` -- `Secret scan / Gitleaks secret scan` -- `CI – Pytest / Run pytest (ubuntu-latest, Python 3.11)` -- `CI – Pytest / Run pytest (ubuntu-latest, Python 3.12)` -- `CI – Pytest / Run pytest (macos-latest, Python 3.11)` -- `CI – Pytest / Run pytest (macos-latest, Python 3.12)` -- `CI – Pytest / Run pytest (windows-latest, Python 3.11)` -- `CI – Pytest / Run pytest (windows-latest, Python 3.12)` -- `Python package security PR checks / Vulnerability scan (packages/runtime)` -- `Python package security PR checks / Vulnerability scan (packages/connectors/http_generic)` -- `Python package security PR checks / Vulnerability scan (packages/connectors/stripe)` -- `Python package security PR checks / Vulnerability scan (packages/connectors/smtp)` -- `Python package security PR checks / Vulnerability scan (packages/connectors/google_drive)` -- `Python package security PR checks / Vulnerability scan (packages/connectors/fhir_cerner)` -- `Python package security PR checks / Vulnerability scan (packages/connectors/fhir_epic)` -- `Python package security PR checks / Vulnerability scan (packages/connectors/salesforce)` -- `Python package security PR checks / Vulnerability scan (packages/connectors/slack)` - -Configure branch protection so pull requests cannot merge unless all required checks pass. - -## CVE scanning policy - -- PR and push-to-main scanning runs in `.github/workflows/security-pr.yml`. -- Release-time scanning remains in `.github/workflows/publish.yml` as defense in depth. -- The PR/push gate (`security-pr.yml`) runs `pip-audit` with no `--fail-on` threshold, so it **blocks on any vulnerability**. The release workflow (`publish.yml`) uses `pip-audit --fail-on HIGH` as defense in depth. -- Scheduled scans catch newly disclosed CVEs even when code does not change. - -**Monorepo install note:** Connector packages under `packages/connectors/*` declare `node-wire-runtime>=1.0.0` as a normal PyPI dependency name. The security workflow installs `packages/runtime` from the checkout **together with** each matrix package (`pip install packages/runtime ""`) so `pip` can resolve `node-wire-runtime` without requiring a published wheel on PyPI. Locally, mirror that when auditing a single connector: `pip install packages/runtime packages/connectors/`. - -## Secret scanning - -Workflow: `.github/workflows/secret-scan.yml` (Gitleaks). - -Policy: - -- Scan on every PR and push to `main`/`master`, plus a weekly scheduled run. -- Full repository history is included (`fetch-depth: 0`). -- Findings fail the workflow; remediate by rotating exposed credentials and - removing secrets from the codebase (never commit live secrets). - -### Run locally - -Install [Gitleaks](https://github.com/gitleaks/gitleaks) (e.g. `brew install gitleaks` -on macOS), then from the repository root: - -```bash -# Working tree (staged + unstaged changes vs HEAD) -gitleaks detect --source . --redact --verbose - -# Full git history (matches CI intent) -gitleaks detect --source . --redact --verbose --log-opts="--all" -``` - -If GitHub Advanced Security secret scanning is enabled at the organization level, -treat it as defense in depth; the in-repo workflow provides auditable CI evidence. - -## Run checks locally - -```bash -# Install dev tools from committed lockfile -uv sync --frozen --all-extras --dev - -# Security gate (matches CI failure threshold) -uv run bandit -c pyproject.toml -r src --severity-level high - -# Optional: JSON report + same summary as CI logs -uv run bandit -c pyproject.toml -r src -f json -o bandit-report.json --exit-zero -python scripts/bandit_report_summary.py bandit-report.json - -# Tests + coverage (run via pytest.yml in CI) -uv run pytest tests/ -v -``` - -## Deterministic pytest environment - -To keep pytest collection and REST app startup deterministic, `tests/conftest.py` sets a fixed environment before imports: - -- `NW_REST_LOAD_DOTENV=false` so REST startup does not merge a repo-root `.env` over test variables. -- `NW_CONFIG_PATH=tests/fixtures/connectors_for_tests.yaml` so optional connectors outside the pytest allowlist remain `enabled: false` (for example `slack` and `salesforce`). -- `NW_ALLOWED_CONNECTORS=http_generic,smtp,stripe,google_drive,fhir_epic,fhir_cerner` so only the supported test connector set is loaded during collection. - -Do not rely on `.env` values during pytest collection. The test harness intentionally overrides them so local developer state does not affect CI or test outcomes. - -### Pre-commit - -```bash -pre-commit install -pre-commit run --all-files -``` - -## Bandit policy - -Bandit is configured in `pyproject.toml` under `[tool.bandit]`. - -### Exit codes and CI behavior - -By default, **Bandit exits with a non-zero status whenever it reports any finding**, including low and medium severity. That affects `-f json -o ...` the same as text output. - -CI splits responsibilities: - -1. **JSON artifact + log summary** — `bandit ... -f json -o bandit-report.json --exit-zero` so the workflow always produces the report and runs `scripts/bandit_report_summary.py` for readable logs. Low/medium issues are visible here without failing the job. -2. **Enforcement** — `bandit ... --severity-level high` fails the job only on high-severity findings (matches branch-protection intent). - -Locally, mirror CI with the commands in [Run checks locally](#run-checks-locally). - -### Scope - -Policy: - -- Scan target: `src/` (runtime, bindings, in-tree connector implementations installed via the root package). -- Exclude: `.venv`, `venv`, `tests`, `playground`, `dist`, `htmlcov`. -- CI enforcement threshold: `--severity-level high`. -- **Packages tree:** connector distributions under `packages/connectors/*` are audited for CVEs in `.github/workflows/security-pr.yml` (`pip-audit`). Run Bandit against those paths separately if you need SAST on a standalone checkout. - -If legacy findings block adoption, create a baseline once and track deltas: - -```bash -bandit -c pyproject.toml -r src -f json -o bandit-baseline.json --exit-zero -bandit -c pyproject.toml -r src --baseline bandit-baseline.json --severity-level high -``` - -## SBOM generation - -CycloneDX SBOM (`sbom.json`) is generated by: - -- `scripts/run-compliance-checks.sh` for local compliance runs. -- `.github/workflows/publish.yml` at release time. - -## Acceptance criteria mapping - -- Security scan runs on every PR: enforced by `quality-gates.yml` (Bandit) and `codeql.yml` (CodeQL). -- Builds fail on high-severity Bandit findings: Bandit gate in CI. -- Static analysis visible in GitHub Security tab: CodeQL upload from CI. -- Tests run on every PR: enforced by `pytest.yml` (Linux/macOS/Windows × Python 3.11/3.12). -- Developers run checks locally: documented commands and pre-commit (Bandit). -- Config version-controlled: `pyproject.toml`, `.pre-commit-config.yaml`, workflow files. +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + +# Quality and security gates + +This document defines how Node Wire enforces security scanning in CI. + +This repository enforces security gates at both PR time and publish time. + +## CI quality gates + +Workflow: `.github/workflows/quality-gates.yml` + +Runs on every pull request and on pushes to `main`/`master`. + +Required jobs: + +- `bandit`: writes `bandit-report.json` (with `--exit-zero` so low/medium findings do not fail the job before the gate), prints a log summary, uploads the artifact, then fails only on **high**-severity findings in the enforce step. + +Workflow: `.github/workflows/codeql.yml` + +Runs GitHub CodeQL static analysis for Python on pull requests, pushes to `main`/`master`, and weekly (Mondays). No repository secrets are required. + +Workflow: `.github/workflows/pytest.yml` + +Runs the full test suite on **Linux, macOS, and Windows** (Python 3.11 and 3.12 +matrix) with coverage on every pull request and push to `main`/`master`. +Playground integration tests remain manual (`workflow_dispatch`) on Ubuntu only. + +Workflow: `.github/workflows/lint.yml` also runs `lockfile-check` (`uv lock --check`) to fail PRs when `pyproject.toml` changes without an updated `uv.lock`. + +Workflow: `.github/workflows/secret-scan.yml` + +Runs [Gitleaks](https://github.com/gitleaks/gitleaks) on pull requests, pushes to +`main`/`master`, weekly (Mondays), and on manual dispatch. The workflow checks +out **full git history** (`fetch-depth: 0`) so secrets in past commits are +scanned, not only the working tree. + +Required checks to add in branch protection: + +- `Lint and Type Check / Lockfile freshness` +- `Quality gates / Bandit security scan` +- `CodeQL / Analyze (Python)` +- `Secret scan / Gitleaks secret scan` +- `CI – Pytest / Run pytest (ubuntu-latest, Python 3.11)` +- `CI – Pytest / Run pytest (ubuntu-latest, Python 3.12)` +- `CI – Pytest / Run pytest (macos-latest, Python 3.11)` +- `CI – Pytest / Run pytest (macos-latest, Python 3.12)` +- `CI – Pytest / Run pytest (windows-latest, Python 3.11)` +- `CI – Pytest / Run pytest (windows-latest, Python 3.12)` +- `Python package security PR checks / Vulnerability scan (packages/runtime)` +- `Python package security PR checks / Vulnerability scan (packages/connectors/http_generic)` +- `Python package security PR checks / Vulnerability scan (packages/connectors/stripe)` +- `Python package security PR checks / Vulnerability scan (packages/connectors/smtp)` +- `Python package security PR checks / Vulnerability scan (packages/connectors/google_drive)` +- `Python package security PR checks / Vulnerability scan (packages/connectors/fhir_cerner)` +- `Python package security PR checks / Vulnerability scan (packages/connectors/fhir_epic)` +- `Python package security PR checks / Vulnerability scan (packages/connectors/salesforce)` +- `Python package security PR checks / Vulnerability scan (packages/connectors/slack)` + +Configure branch protection so pull requests cannot merge unless all required checks pass. + +## CVE scanning policy + +- PR and push-to-main scanning runs in `.github/workflows/security-pr.yml`. +- Release-time scanning remains in `.github/workflows/publish.yml` as defense in depth. +- The PR/push gate (`security-pr.yml`) runs `pip-audit` with no `--fail-on` threshold, so it **blocks on any vulnerability**. The release workflow (`publish.yml`) uses `pip-audit --fail-on HIGH` as defense in depth. +- Scheduled scans catch newly disclosed CVEs even when code does not change. + +**Monorepo install note:** Connector packages under `packages/connectors/*` declare `node-wire-runtime>=1.0.0` as a normal PyPI dependency name. The security workflow installs `packages/runtime` from the checkout **together with** each matrix package (`pip install packages/runtime ""`) so `pip` can resolve `node-wire-runtime` without requiring a published wheel on PyPI. Locally, mirror that when auditing a single connector: `pip install packages/runtime packages/connectors/`. + +## Secret scanning + +Workflow: `.github/workflows/secret-scan.yml` (Gitleaks). + +Policy: + +- Scan on every PR and push to `main`/`master`, plus a weekly scheduled run. +- Full repository history is included (`fetch-depth: 0`). +- Findings fail the workflow; remediate by rotating exposed credentials and + removing secrets from the codebase (never commit live secrets). + +### Run locally + +Install [Gitleaks](https://github.com/gitleaks/gitleaks) (e.g. `brew install gitleaks` +on macOS), then from the repository root: + +```bash +# Working tree (staged + unstaged changes vs HEAD) +gitleaks detect --source . --redact --verbose + +# Full git history (matches CI intent) +gitleaks detect --source . --redact --verbose --log-opts="--all" +``` + +If GitHub Advanced Security secret scanning is enabled at the organization level, +treat it as defense in depth; the in-repo workflow provides auditable CI evidence. + +## Run checks locally + +```bash +# Install dev tools from committed lockfile +uv sync --frozen --all-extras --dev + +# Security gate (matches CI failure threshold) +uv run bandit -c pyproject.toml -r src --severity-level high + +# Optional: JSON report + same summary as CI logs +uv run bandit -c pyproject.toml -r src -f json -o bandit-report.json --exit-zero +python scripts/bandit_report_summary.py bandit-report.json + +# Tests + coverage (run via pytest.yml in CI) +uv run pytest tests/ -v +``` + +## Deterministic pytest environment + +To keep pytest collection and REST app startup deterministic, `tests/conftest.py` sets a fixed environment before imports: + +- `NW_REST_LOAD_DOTENV=false` so REST startup does not merge a repo-root `.env` over test variables. +- `NW_CONFIG_PATH=tests/fixtures/connectors_for_tests.yaml` so optional connectors outside the pytest allowlist remain `enabled: false` (for example `slack` and `salesforce`). +- `NW_ALLOWED_CONNECTORS=http_generic,smtp,stripe,google_drive,fhir_epic,fhir_cerner` so only the supported test connector set is loaded during collection. + +Do not rely on `.env` values during pytest collection. The test harness intentionally overrides them so local developer state does not affect CI or test outcomes. + +### Pre-commit + +```bash +pre-commit install +pre-commit run --all-files +``` + +## Bandit policy + +Bandit is configured in `pyproject.toml` under `[tool.bandit]`. + +### Exit codes and CI behavior + +By default, **Bandit exits with a non-zero status whenever it reports any finding**, including low and medium severity. That affects `-f json -o ...` the same as text output. + +CI splits responsibilities: + +1. **JSON artifact + log summary** — `bandit ... -f json -o bandit-report.json --exit-zero` so the workflow always produces the report and runs `scripts/bandit_report_summary.py` for readable logs. Low/medium issues are visible here without failing the job. +2. **Enforcement** — `bandit ... --severity-level high` fails the job only on high-severity findings (matches branch-protection intent). + +Locally, mirror CI with the commands in [Run checks locally](#run-checks-locally). + +### Scope + +Policy: + +- Scan target: `src/` (runtime, bindings, in-tree connector implementations installed via the root package). +- Exclude: `.venv`, `venv`, `tests`, `playground`, `dist`, `htmlcov`. +- CI enforcement threshold: `--severity-level high`. +- **Packages tree:** connector distributions under `packages/connectors/*` are audited for CVEs in `.github/workflows/security-pr.yml` (`pip-audit`). Run Bandit against those paths separately if you need SAST on a standalone checkout. + +If legacy findings block adoption, create a baseline once and track deltas: + +```bash +bandit -c pyproject.toml -r src -f json -o bandit-baseline.json --exit-zero +bandit -c pyproject.toml -r src --baseline bandit-baseline.json --severity-level high +``` + +## SBOM generation + +CycloneDX SBOM (`sbom.json`) is generated by: + +- `scripts/run-compliance-checks.sh` for local compliance runs. +- `.github/workflows/github-release.yml` at release time (attached to the GitHub Release). + +## Acceptance criteria mapping + +- Security scan runs on every PR: enforced by `quality-gates.yml` (Bandit) and `codeql.yml` (CodeQL). +- Builds fail on high-severity Bandit findings: Bandit gate in CI. +- Static analysis visible in GitHub Security tab: CodeQL upload from CI. +- Tests run on every PR: enforced by `pytest.yml` (Linux/macOS/Windows × Python 3.11/3.12). +- Developers run checks locally: documented commands and pre-commit (Bandit). +- Config version-controlled: `pyproject.toml`, `.pre-commit-config.yaml`, workflow files. diff --git a/docs/release-rollback.md b/docs/release-rollback.md index 40bf449..82abdd3 100644 --- a/docs/release-rollback.md +++ b/docs/release-rollback.md @@ -80,7 +80,7 @@ at the bad version). 3. Update [CHANGELOG.md](../CHANGELOG.md) with the fix and yank notice. 4. Run the local pre-publish checklist in [packaging.md](packaging.md#pre-pypi-local-validation-checklist). 5. Dispatch `.github/workflows/publish.yml` for each affected `package_path` - with the new `version` input. + with the corrective release `tag` (e.g. `v1.0.1`). ## Step 4 — GitHub release and tags From b54432f29f81b0c52b9d48101d036e3bd2655aeb Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:52:51 -0700 Subject: [PATCH 17/20] Add addressed cases to gitleaks --- .gitleaks.toml | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .gitleaks.toml diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..efd7531 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 + +title = "Node Wire gitleaks configuration" + +[allowlist] +description = "Known false positives: placeholder values, test fixtures, and documentation stubs" + +# regexTarget = "secret" matches against the extracted secret value only, +# not surrounding context — keeps these allowlist rules as narrow as possible. +regexTarget = "secret" + +regexes = [ + # sample.env instructional placeholder — not a real credential. + '''replace-with-hs256-secret''', + + # Stripe's canonical documentation test token. This specific value is + # published in Stripe's own public docs and is not a live key. + '''sk_test_4eC39HqLyjWDarjtT1zdp7dc''', + + # RSA/EC key stubs used in documentation examples whose body is the literal + # string "..." — structurally valid PEM headers with no real key material. + # Real keys never contain "..." as body content. + '''\.\.\.''', +] + +# tests/test_payload_redaction.py contains dummy PEM key material (body marked +# "...dummy...") that is required to exercise the payload-redaction logic. +# The file exists solely to test that fake-looking keys are redacted correctly. +paths = [ + '''tests/test_payload_redaction\.py''', +] + +# Historical commits where documentation used RSA key stubs or Stripe's test +# token. These values were removed from the working tree in later commits. +commits = [ + "1d14bc1236beb7da3687e2eae648f07f947c36b8", # Initial Commit — Setup.md RSA stub + "65ecd72a4d635359a1242f5b4c3d3c4d09916caa", # Feature/merge prs — Setup.md RSA stub + "ab75ce91057be702e33fa4a25d5a8d189393e87b", # Stripe connector — docs Stripe test token + RSA stub + "ccd272e5793c1625302c9bf19e1f489e9af8bdfd", # Added Multiple MCPs — docs RSA stub +] + +# ── ACTION REQUIRED: GCP service account key ───────────────────────────────── +# +# File connectorplatform-180d995a1c96.json (a real Google service account JSON +# key) was committed and is present in the two commits below. The file has since +# been removed from the working tree but the key material remains in git history. +# +# BEFORE adding these commits to the allowlist above: +# 1. Delete or disable the key in GCP Console: +# https://console.cloud.google.com/iam-admin/serviceaccounts?project=connectorplatform +# Service account : node-wire-test@connectorplatform.iam.gserviceaccount.com +# Key ID : 180d995a1c968838d624f6d650b7e5ad8ac8c05c +# 2. Confirm the key is revoked and no service depends on it. +# 3. Move the two SHAs below into the commits list and remove this comment block. +# +"0e26561e7e956dd333252860881cf41c804a2dc8", # Feature/python packages — connectorplatform key +"92f85f2c22fe1d0659828781e3afb4eb242c4048", # Added connector package — connectorplatform key From af8729bf28d0829cc8898065e6a93b0c4ef96a8e Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:55:44 -0700 Subject: [PATCH 18/20] Disable codeql until public release --- .github/workflows/codeql.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a5afe1d..8e2dac9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -7,12 +7,7 @@ name: CodeQL on: - push: - branches: [main, master] - pull_request: - branches: [main, master] - schedule: - - cron: "0 6 * * 1" + workflow_dispatch: jobs: analyze: From 0cf9584edaedfb72e7a5753acda0dc7c00c32139 Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:58:56 -0700 Subject: [PATCH 19/20] Fixed gitleaks --- .gitleaks.toml | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/.gitleaks.toml b/.gitleaks.toml index efd7531..35836f2 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -38,21 +38,6 @@ commits = [ "65ecd72a4d635359a1242f5b4c3d3c4d09916caa", # Feature/merge prs — Setup.md RSA stub "ab75ce91057be702e33fa4a25d5a8d189393e87b", # Stripe connector — docs Stripe test token + RSA stub "ccd272e5793c1625302c9bf19e1f489e9af8bdfd", # Added Multiple MCPs — docs RSA stub + "0e26561e7e956dd333252860881cf41c804a2dc8", # Feature/python packages — connectorplatform key (revoked) + "92f85f2c22fe1d0659828781e3afb4eb242c4048", # Added connector package — connectorplatform key (revoked) ] - -# ── ACTION REQUIRED: GCP service account key ───────────────────────────────── -# -# File connectorplatform-180d995a1c96.json (a real Google service account JSON -# key) was committed and is present in the two commits below. The file has since -# been removed from the working tree but the key material remains in git history. -# -# BEFORE adding these commits to the allowlist above: -# 1. Delete or disable the key in GCP Console: -# https://console.cloud.google.com/iam-admin/serviceaccounts?project=connectorplatform -# Service account : node-wire-test@connectorplatform.iam.gserviceaccount.com -# Key ID : 180d995a1c968838d624f6d650b7e5ad8ac8c05c -# 2. Confirm the key is revoked and no service depends on it. -# 3. Move the two SHAs below into the commits list and remove this comment block. -# -"0e26561e7e956dd333252860881cf41c804a2dc8", # Feature/python packages — connectorplatform key -"92f85f2c22fe1d0659828781e3afb4eb242c4048", # Added connector package — connectorplatform key From 9b8a8cd2517fcfc4657c9f839a17a0ee92dede73 Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:23:40 -0700 Subject: [PATCH 20/20] Remove Auth from playground UI. Added better logging for errors --- src/bindings/rest_api/auth.py | 19 ++++++++++++++++--- src/node_wire_runtime/auth/oauth2.py | 21 +++++++++++++++------ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/bindings/rest_api/auth.py b/src/bindings/rest_api/auth.py index ae81c98..2e05cc4 100644 --- a/src/bindings/rest_api/auth.py +++ b/src/bindings/rest_api/auth.py @@ -10,7 +10,7 @@ NW_REST_JWT_SECRET — optional HS256 secret; if set, Bearer tokens with three segments are verified as JWTs. NW_REST_AUTH_DISABLED — if ``true``/``1``/``yes``, skip auth (local dev only; do not use in production). -Public (unauthenticated): ``GET /health`` only. OpenAPI UI requires auth. +Public (unauthenticated): ``GET /health``, ``/docs``, ``/redoc``, ``/openapi.json``, ``/playground/*``, ``/scenarios/*``. Auth is required for ``/connectors/*`` only. After successful auth, normalized caller identity (principal / tenant_id / scopes) is stored on ``request.state.nw_rest_caller_identity`` and forwarded to ``connector.run`` for policy hooks. @@ -48,7 +48,15 @@ def _truthy(val: str | None) -> bool: def _is_public_path(path: str) -> bool: p = path.rstrip("/") or "/" - return p == "/health" + return ( + p == "/health" + or p in ("/docs", "/redoc", "/openapi.json") + or p.startswith("/docs/") + or p == "/playground" + or p.startswith("/playground/") + or p == "/scenarios" + or p.startswith("/scenarios/") + ) def _trusted_proxy_hops() -> int: @@ -159,7 +167,12 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response: if not token: return JSONResponse( status_code=401, - content={"detail": "Authentication required"}, + content={ + "detail": ( + "Authentication required. Provide an 'Authorization: Bearer ' " + "or 'X-API-Key: ' header to call connector endpoints." + ) + }, headers={"WWW-Authenticate": 'Bearer realm="node-wire"'}, ) diff --git a/src/node_wire_runtime/auth/oauth2.py b/src/node_wire_runtime/auth/oauth2.py index 103aeb6..0c6d95f 100644 --- a/src/node_wire_runtime/auth/oauth2.py +++ b/src/node_wire_runtime/auth/oauth2.py @@ -306,12 +306,21 @@ async def _fetch_private_key_jwt(self) -> Dict[str, Any]: if scope: claims["scope"] = scope - jwt_token = jwt.encode( - claims, - private_key_pem, - algorithm=self._algorithm, - headers={"alg": self._algorithm, "typ": "JWT", "kid": kid}, - ) + try: + jwt_token = jwt.encode( + claims, + private_key_pem, + algorithm=self._algorithm, + headers={"alg": self._algorithm, "typ": "JWT", "kid": kid}, + ) + except jwt.exceptions.InvalidKeyError as exc: + raise ValueError( + f"OAuth2 private_key_jwt: could not sign the JWT assertion — the private key is " + f"invalid or in an unexpected format (algorithm: {self._algorithm}). " + f"Check that the secret referenced by 'private_key_secret' contains a valid " + f"PEM-encoded RSA or EC private key (not a public key or certificate). " + f"Detail: {exc}" + ) from exc post_data: Dict[str, str] = { "grant_type": "client_credentials",