diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..93c73c2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{json,yml,yaml}] +indent_size = 2 + +[{*.markdown,*.md}] +ij_markdown_wrap_text_if_long = false +ij_markdown_keep_line_breaks_inside_text_blocks = true \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..562dfcb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +openapi.json linguist-generated=true +uv.lock linguist-generated=true \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d4e73d9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + + - package-ecosystem: uv + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e3cf4f2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + contract: + name: OpenAPI contract + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --locked + + - name: Lint + run: uv run --locked ruff check . + + - name: Type check + run: uv run --locked pyright + + - name: Test + run: uv run --locked pytest diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..3a4f41e --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..531df37 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.PHONY: dev export-openapi lint test + +dev: + uv run fastapi dev src/nc3_testing_platform/main.py + +export-openapi: + uv run export-openapi + +lint: + uv run ruff check . + +test: + uv run pytest diff --git a/README.md b/README.md index 4482406..1bbdcb0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Project repository for the NC3 Testing Platform backend (v4). - Python 3.13, [uv](https://docs.astral.sh/uv/) (packaging + virtualenv) - FastAPI + Pydantic — the app and its request/response models -- openapi-spec-validator (dev) — validates the generated 3.1 spec +- pytest + openapi-spec-validator (dev) — the contract test suite **Projected** — planned: @@ -28,7 +28,7 @@ No environment variables or config are required yet. # Running the mock server ```bash -uv run fastapi dev app/main.py +make dev ``` - API base: http://localhost:8000/api/v1 @@ -40,15 +40,17 @@ Handlers return static stub data, so the running server doubles as a mock the fr # Generating the OpenAPI contract -The OpenAPI 3.1 spec is generated from the FastAPI app (`app.main:app`) and written to `docs/openapi.json`. +The OpenAPI 3.1 spec is generated from the FastAPI app (`nc3_testing_platform.main:app`) and written to `api/openapi.json`. ```bash -uv run python -m app.tools.export_openapi # write docs/openapi.json -uv run openapi-spec-validator --schema 3.1 docs/openapi.json # validate (exits 0 if valid) +make export-openapi # write api/openapi.json +make lint # ruff over the source +make test # validate it, and check the committed file is current ``` -Regenerate and re-validate after any change to a router or Pydantic schema. `docs/openapi.json` is the contract the -frontend interfaces with; commit it alongside the change that alters it. +The development routine after any change to a router or Pydantic schema is `make export-openapi && make lint`. `api/openapi.json` is the contract the frontend interfaces with; commit it alongside the change that alters it. + +`make test` validates the generated document against OpenAPI 3.1 and fails if the committed file differs from it. CI runs the same command. # Project structure @@ -56,7 +58,7 @@ frontend interfaces with; commit it alongside the change that alters it. > return stub data so the app runs as a live mock; there is no persistence, auth backend, or scan logic yet. ``` -app/ +src/nc3_testing_platform/ main.py # FastAPI app; mounts every domain router under /api/v1 core/ # shared, cross-cutting building blocks enums.py # canonical enums @@ -64,12 +66,17 @@ app/ errors.py # RFC 9457 problem+json errors + handlers pagination.py # cursor pagination security.py # OpenAPI security schemes + rate-limit contract - domains/ # one vertical slice per domain (router + schemas together) - guest/ auth/ org/ assets/ scans/ - schedules/ findings/ reports/ notifications/ health/ + domains/ # one vertical slice per domain + scans/ # every slice follows this layout + models.py # SQLAlchemy models + schemas.py # Pydantic request and response models + repository.py # queries; session is the first argument + service.py # business logic and transaction boundaries + router.py # path operations tools/ - export_openapi.py # dumps app.openapi() -> docs/openapi.json + export_openapi.py # dumps app.openapi() -> api/openapi.json +api/ + openapi.json # generated API contract (see "Generating the OpenAPI contract") docs/ - openapi.json # generated contract (see "Generating the OpenAPI contract") - reference/ # source design docs (data-model, ADRs) -``` \ No newline at end of file + reference/ # reference documentation +``` diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..2ec2cf1 --- /dev/null +++ b/api/README.md @@ -0,0 +1,15 @@ +# API contract + +`openapi.json` is the generated OpenAPI 3.1 contract for the v4 backend. +It is committed so the frontend and client generators can consume it without a Python toolchain, and so contract changes +show up as reviewable PR diffs. + +Do not edit it by hand. +The source of truth is the FastAPI app; regenerate after any schema or route change: + +```bash +make export-openapi +``` + +Commit the regenerated file together with the code change that caused it. +CI validates the committed spec and fails if it drifts from what the app generates. \ No newline at end of file diff --git a/api/openapi.json b/api/openapi.json new file mode 100644 index 0000000..767965e --- /dev/null +++ b/api/openapi.json @@ -0,0 +1,8347 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "NC3 Testing Platform API", + "summary": "v4.0 backend MVP for the NC3 Testing Platform.", + "version": "4.0.1" + }, + "paths": { + "/api/v1/scans": { + "post": { + "tags": [ + "scans" + ], + "summary": "Launch a scan", + "description": "Launch a domain or file scan.\n\nReturns `202` with the job resource. An unauthenticated launch also returns the\none-time token needed to claim the scan after registering.\n\nThe application performs the launch in a fixed order: allocate the job\nidentifier, record any required declarations against it, evaluate the gates,\ncreate job and task state in one transaction, enqueue only once that state is\ndurable, then respond. Gates — authorization, verification, current MFA\nassurance, rate, and cooldown — are evaluated from the request context and the\nselected tests. None of them is a field the caller sends.", + "operationId": "launch_scan_api_v1_scans_post", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + }, + {} + ], + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanJobAccepted" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + }, + "headers": { + "RateLimit": { + "schema": { + "type": "string" + }, + "description": "Quota-window state, e.g. `limit=100, remaining=42, reset=30`." + }, + "RateLimit-Policy": { + "schema": { + "type": "string" + }, + "description": "Advertised quota policy, e.g. `100;w=60`." + }, + "Retry-After": { + "schema": { + "type": "integer" + }, + "description": "Seconds until the quota resets. Sent with `429`." + } + } + } + }, + "requestBody": { + "required": true, + "description": "The request schema is selected by media type, and within JSON by access state.\n\n- `application/json` + authenticated caller → `AssetScanLaunch` (`asset_id`, an Asset in the caller's organization).\n- `application/json` + anonymous caller → `GuestScanLaunch` (`target`, a canonical domain).\n- `multipart/form-data` → `FileScanLaunch` (one `file` part, no target).\n\nSupplying the field belonging to the other access state returns `422`. No schema contains an `asset_id | target | file` union, and no other media type is accepted.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/AssetScanLaunch" + }, + { + "$ref": "#/components/schemas/GuestScanLaunch" + } + ] + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/FileScanLaunch" + } + } + } + } + }, + "get": { + "tags": [ + "scans" + ], + "summary": "List scans", + "description": "Scans in the caller's organization, newest first.\n\nGuest scans appear here once they are claimed.", + "operationId": "list_scans_api_v1_scans_get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor returned as `next_cursor` by the previous page.", + "title": "Cursor" + }, + "description": "Opaque cursor returned as `next_cursor` by the previous page." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "description": "Max items per page.", + "default": 50, + "title": "Limit" + }, + "description": "Max items per page." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_ScanJob_" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/scans/{scan_id}": { + "get": { + "tags": [ + "scans" + ], + "summary": "Get a scan with its task snapshot", + "description": "The authoritative job and task state.\n\nFetch this before subscribing to the event stream and again after reconnection\nor whenever an applied event leaves the client uncertain.", + "operationId": "get_scan_api_v1_scans__scan_id__get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + }, + {} + ], + "parameters": [ + { + "name": "scan_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Scan Id" + } + }, + { + "name": "claim_token", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "One-time token returned by an unauthenticated launch. Required to read a guest scan that has not been claimed; ignored when the caller is authenticated and owns the scan. Reading does not consume it.", + "title": "Claim Token" + }, + "description": "One-time token returned by an unauthenticated launch. Required to read a guest scan that has not been claimed; ignored when the caller is authenticated and owns the scan. Reading does not consume it." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanJobDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + }, + "delete": { + "tags": [ + "scans" + ], + "summary": "Hard-delete a scan", + "description": "Delete the scan and its data immediately.\n\nDistinct from cancellation, which stops execution and keeps the history.", + "operationId": "delete_scan_api_v1_scans__scan_id__delete", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "scan_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Scan Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/scans/{scan_id}/results": { + "get": { + "tags": [ + "scans" + ], + "summary": "Get scan results", + "description": "One result per completed task.\n\nNot paginated: the collection is bounded by the job's task count, which the\nexecutable-test catalog caps well below a page.", + "operationId": "get_scan_results_api_v1_scans__scan_id__results_get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + }, + {} + ], + "parameters": [ + { + "name": "scan_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Scan Id" + } + }, + { + "name": "claim_token", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "One-time token returned by an unauthenticated launch. Required to read a guest scan that has not been claimed; ignored when the caller is authenticated and owns the scan. Reading does not consume it.", + "title": "Claim Token" + }, + "description": "One-time token returned by an unauthenticated launch. Required to read a guest scan that has not been claimed; ignored when the caller is authenticated and owns the scan. Reading does not consume it." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScanResult" + }, + "title": "Response Get Scan Results Api V1 Scans Scan Id Results Get" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/scans/{scan_id}/events": { + "get": { + "tags": [ + "scans" + ], + "summary": "Stream live scan progress", + "description": "Advisory server-sent events for a running scan.\n\nDatabase state is authoritative; these events only reduce latency.\nThe snapshot is the only recovery for missed events: refetch it after a reconnect.", + "operationId": "stream_scan_events_api_v1_scans__scan_id__events_get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + }, + {} + ], + "parameters": [ + { + "name": "scan_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Scan Id" + } + }, + { + "name": "claim_token", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "One-time token returned by an unauthenticated launch. Required to read a guest scan that has not been claimed; ignored when the caller is authenticated and owns the scan. Reading does not consume it.", + "title": "Claim Token" + }, + "description": "One-time token returned by an unauthenticated launch. Required to read a guest scan that has not been claimed; ignored when the caller is authenticated and owns the scan. Reading does not consume it." + } + ], + "responses": { + "200": { + "description": "Advisory progress events until the job reaches a terminal state. The SSE `event:` line selects the payload:\n\n- `task` → `ScanTaskEvent`, one task changed state\n- `job` → `ScanJobEvent`, the job changed state\n- `heartbeat` → `ScanHeartbeatEvent`, sent on an interval\n- `end` → `ScanEndEvent`, terminal state reached, no further events\n\nDatabase state is authoritative. Refetch the snapshot after a reconnect or whenever an applied event leaves the client uncertain.", + "content": { + "text/event-stream": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ScanTaskEvent" + }, + { + "$ref": "#/components/schemas/ScanJobEvent" + }, + { + "$ref": "#/components/schemas/ScanHeartbeatEvent" + }, + { + "$ref": "#/components/schemas/ScanEndEvent" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/scans/{scan_id}/cancel": { + "post": { + "tags": [ + "scans" + ], + "summary": "Cancel a running scan", + "description": "Record durable cancellation intent.\n\nScan history is preserved: canceling is not deletion, and `DELETE` is never\nused to stop execution. Workers check the intent before starting a task and at\nsafe interruption points; a canceled task cannot later produce an accepted\nsuccessful result.", + "operationId": "cancel_scan_api_v1_scans__scan_id__cancel_post", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "scan_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Scan Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanJob" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/scans/{scan_id}/claim": { + "post": { + "tags": [ + "scans" + ], + "summary": "Claim a guest scan", + "description": "Attach a guest scan to the authenticated caller's organization.\n\nOne atomic compare-and-set: the job must be an unclaimed guest job whose stored\nhash matches the supplied token and whose retention has not lapsed. Success\nrecords the claiming user and organization and discards the stored hash, so the\ntoken cannot be spent twice. For a file scan it also restores organization\nscoping on the upload metadata.\n\nEvery failure answers `404` — wrong token, already claimed, and lapsed are\nindistinguishable from outside. Anything more specific would let a caller\nholding no token confirm that a scan exists and learn its state.", + "operationId": "claim_scan_api_v1_scans__scan_id__claim_post", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "scan_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Scan Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanClaimRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanJob" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/scans/{scan_id}/retention/extend": { + "post": { + "tags": [ + "scans" + ], + "summary": "Extend the retention deadline", + "description": "Move `purge_at` further out and record an audit event.\n\nNo request body: the interval is policy, not contract.\nRead the new deadline from the response.", + "operationId": "extend_retention_api_v1_scans__scan_id__retention_extend_post", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "scan_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Scan Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanJob" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/assets": { + "get": { + "tags": [ + "assets" + ], + "summary": "List assets", + "description": "Assets owned by the caller's organization.", + "operationId": "list_assets_api_v1_assets_get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor returned as `next_cursor` by the previous page.", + "title": "Cursor" + }, + "description": "Opaque cursor returned as `next_cursor` by the previous page." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "description": "Max items per page.", + "default": 50, + "title": "Limit" + }, + "description": "Max items per page." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_Asset_" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + }, + "post": { + "tags": [ + "assets" + ], + "summary": "Register a domain", + "description": "Register a domain to monitor.\n\nConflicts with an existing asset for the same organization and value.", + "operationId": "create_asset_api_v1_assets_post", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Asset" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/assets/{asset_id}": { + "get": { + "tags": [ + "assets" + ], + "summary": "Get an asset", + "description": "One asset. Verification state is a separate nested resource.", + "operationId": "get_asset_api_v1_assets__asset_id__get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Asset Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Asset" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + }, + "patch": { + "tags": [ + "assets" + ], + "summary": "Update an asset", + "description": "Change regression alerting. Nothing else about an asset is mutable.", + "operationId": "update_asset_api_v1_assets__asset_id__patch", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Asset Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Asset" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + }, + "delete": { + "tags": [ + "assets" + ], + "summary": "Delete an asset", + "description": "Remove an asset from the inventory.\n\nAnswers `409` while scan history, discovered children, a verification, a schedule, or a feed reference the asset.", + "operationId": "delete_asset_api_v1_assets__asset_id__delete", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Asset Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/assets/{asset_id}/scans": { + "get": { + "tags": [ + "assets" + ], + "summary": "List scans for an asset", + "description": "This asset's scan history, newest first.", + "operationId": "list_asset_scans_api_v1_assets__asset_id__scans_get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Asset Id" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor returned as `next_cursor` by the previous page.", + "title": "Cursor" + }, + "description": "Opaque cursor returned as `next_cursor` by the previous page." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "description": "Max items per page.", + "default": 50, + "title": "Limit" + }, + "description": "Max items per page." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_ScanJob_" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/assets/{asset_id}/verification": { + "get": { + "tags": [ + "assets" + ], + "summary": "Get verification state", + "description": "Current ownership-verification state. `404` when none was ever started.", + "operationId": "get_verification_api_v1_assets__asset_id__verification_get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Asset Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DomainVerification" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + }, + "post": { + "tags": [ + "assets" + ], + "summary": "Start a verification challenge", + "description": "Issue a challenge at the requested coverage.\n\nRequires current MFA assurance, read from the OIDC token rather than from any\nstored flag — proving control of a domain is what later authorizes scanning it.\n\nOn an already-verified asset the response carries both the standing proof and\nthe new challenge, so coverage in force is never withdrawn while ownership is\nre-proven.", + "operationId": "create_verification_api_v1_assets__asset_id__verification_post", + "security": [ + { + "OpenIdConnect": [] + } + ], + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Asset Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerificationCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DomainVerification" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/assets/{asset_id}/verification/checks": { + "post": { + "tags": [ + "assets" + ], + "summary": "Check the DNS record now", + "description": "Resolve the challenge record and update the state.\n\nAlways sets `last_recheck_at`, so a user who presses this while DNS is still\npropagating can see that it ran. Propagation takes anywhere from minutes to\ntwo days, which makes \"not found yet\" the common outcome rather than an\nexceptional one.\n\nA check that ran and found nothing is a result, not a fault: the response is\n`200` with the state still `pending` and a `failure_code` saying why.", + "operationId": "check_verification_api_v1_assets__asset_id__verification_checks_post", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Asset Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DomainVerification" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/assets/{asset_id}/verification/token": { + "post": { + "tags": [ + "assets" + ], + "summary": "Replace the verification token", + "description": "Issue a fresh token and expiry for a stalled challenge.\n\nAnswers `409` when the asset is already verified. Regeneration exists for a\nchallenge that expired or whose token was lost, and a verified asset has\nneither — replacing its token would discard a working proof and make the user\nedit DNS again to get back where they started.\n\nTo re-prove ownership, or to widen the scope, start a new challenge with\n`POST .../verification`. The existing `verified_scope` holds until that\nchallenge succeeds, so nothing depending on the current proof breaks meanwhile.", + "operationId": "regenerate_verification_token_api_v1_assets__asset_id__verification_token_post", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Asset Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DomainVerification" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/assets/{asset_id}/feeds": { + "get": { + "tags": [ + "assets" + ], + "summary": "List feeds for an asset", + "description": "Feeds configured for this asset, including revoked ones.", + "operationId": "list_asset_feeds_api_v1_assets__asset_id__feeds_get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Asset Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetFeed" + }, + "title": "Response List Asset Feeds Api V1 Assets Asset Id Feeds Get" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + }, + "post": { + "tags": [ + "assets" + ], + "summary": "Create a feed", + "description": "Create a syndication feed.\n\nThe response is the only time the token and URL are readable; only the hash is\nstored. A lost token is replaced by revoking and creating another.", + "operationId": "create_asset_feed_api_v1_assets__asset_id__feeds_post", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Asset Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetFeedCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetFeedCreated" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/assets/{asset_id}/feeds/{feed_id}/revoke": { + "post": { + "tags": [ + "assets" + ], + "summary": "Revoke a feed", + "description": "Stop serving a feed while keeping its row.\n\nA `POST` rather than a `DELETE`, because revocation is a recorded event and the\nlifecycle survives it.", + "operationId": "revoke_asset_feed_api_v1_assets__asset_id__feeds__feed_id__revoke_post", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Asset Id" + } + }, + { + "name": "feed_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Feed Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetFeed" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/feeds/{token}": { + "get": { + "tags": [ + "assets" + ], + "summary": "Fetch a syndication feed", + "description": "Serve a feed to a subscriber.\n\nUnauthenticated: the token is the authorization. A revoked feed answers `410`,\nwhich tells an aggregator to stop polling rather than to retry.", + "operationId": "get_feed_api_v1_feeds__token__get", + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Token" + } + } + ], + "responses": { + "200": { + "description": "RSS or Atom document, per the feed's configured format.", + "content": { + "application/rss+xml": { + "schema": { + "type": "string" + } + }, + "application/atom+xml": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "410": { + "description": "Gone", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/schedules": { + "get": { + "tags": [ + "schedules" + ], + "summary": "List schedules", + "description": "Schedules owned by the caller's organization.", + "operationId": "list_schedules_api_v1_schedules_get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor returned as `next_cursor` by the previous page.", + "title": "Cursor" + }, + "description": "Opaque cursor returned as `next_cursor` by the previous page." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "description": "Max items per page.", + "default": 50, + "title": "Limit" + }, + "description": "Max items per page." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_Schedule_" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + }, + "post": { + "tags": [ + "schedules" + ], + "summary": "Create a schedule", + "description": "Create a recurring scan against a currently eligible asset.", + "operationId": "create_schedule_api_v1_schedules_post", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Schedule" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/schedules/{schedule_id}": { + "get": { + "tags": [ + "schedules" + ], + "summary": "Get a schedule", + "description": "One schedule, including its next fire time.", + "operationId": "get_schedule_api_v1_schedules__schedule_id__get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "schedule_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Schedule Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Schedule" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + }, + "patch": { + "tags": [ + "schedules" + ], + "summary": "Update a schedule", + "description": "Change recurrence, modules, or enablement.", + "operationId": "update_schedule_api_v1_schedules__schedule_id__patch", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "schedule_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Schedule Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Schedule" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + }, + "delete": { + "tags": [ + "schedules" + ], + "summary": "Delete a schedule", + "description": "Remove a schedule. Scans it already produced are unaffected.", + "operationId": "delete_schedule_api_v1_schedules__schedule_id__delete", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "schedule_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Schedule Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/findings": { + "get": { + "tags": [ + "findings" + ], + "summary": "List findings", + "description": "Findings across the caller's organization.", + "operationId": "list_findings_api_v1_findings_get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "severity", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FindingSeverity" + }, + { + "type": "null" + } + ], + "description": "Filter by severity band.", + "title": "Severity" + }, + "description": "Filter by severity band." + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FindingStatus" + }, + { + "type": "null" + } + ], + "description": "Filter by historical-comparison classification.", + "title": "Status" + }, + "description": "Filter by historical-comparison classification." + }, + { + "name": "asset_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "description": "Restrict to findings raised against one asset.", + "title": "Asset Id" + }, + "description": "Restrict to findings raised against one asset." + }, + { + "name": "scan_job_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "description": "Restrict to findings from one scan.", + "title": "Scan Job Id" + }, + "description": "Restrict to findings from one scan." + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor returned as `next_cursor` by the previous page.", + "title": "Cursor" + }, + "description": "Opaque cursor returned as `next_cursor` by the previous page." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "description": "Max items per page.", + "default": 50, + "title": "Limit" + }, + "description": "Max items per page." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_Finding_" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/findings/{finding_id}": { + "get": { + "tags": [ + "findings" + ], + "summary": "Get a finding", + "description": "One finding in full, including its evidence.", + "operationId": "get_finding_api_v1_findings__finding_id__get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "finding_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Finding Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Finding" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/reports": { + "post": { + "tags": [ + "reports" + ], + "summary": "Generate a report", + "description": "Render a report synchronously from one retained scan source.\n\nAnswers `409` once the source has been purged: the provenance row may still\nexist, but the data it was drawn from no longer does.\n\nThe body is a placeholder of the right type, not a real report. Report content\nis assembled from scan results, and those shapes are owned by the scan modules,\nwhich do not exist yet.", + "operationId": "generate_report_api_v1_reports_post", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportRequest" + } + } + } + }, + "responses": { + "201": { + "description": "The rendered document, in the requested format.To obtain it again, submit another request while the source scan is still retained.", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + }, + "get": { + "tags": [ + "reports" + ], + "summary": "List generated reports", + "description": "Provenance metadata for reports this organization has generated.\n\nMetadata only. No entry here can be turned back into a document; that requires\ngenerating a new one from a source that is still retained.", + "operationId": "list_reports_api_v1_reports_get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor returned as `next_cursor` by the previous page.", + "title": "Cursor" + }, + "description": "Opaque cursor returned as `next_cursor` by the previous page." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "description": "Max items per page.", + "default": 50, + "title": "Limit" + }, + "description": "Max items per page." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_Report_" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/notifications": { + "get": { + "tags": [ + "notifications" + ], + "summary": "List notifications", + "description": "The caller's inbox, newest first.", + "operationId": "list_notifications_api_v1_notifications_get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor returned as `next_cursor` by the previous page.", + "title": "Cursor" + }, + "description": "Opaque cursor returned as `next_cursor` by the previous page." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "description": "Max items per page.", + "default": 50, + "title": "Limit" + }, + "description": "Max items per page." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_Notification_" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/notifications/read-all": { + "post": { + "tags": [ + "notifications" + ], + "summary": "Mark all as read", + "description": "Mark every unread notification belonging to the caller.\n\nReturns no body: the caller already knows the outcome, and re-sending the\ninbox here would duplicate `GET /notifications`.", + "operationId": "mark_all_read_api_v1_notifications_read_all_post", + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + }, + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ] + } + }, + "/api/v1/notifications/{notification_id}/read": { + "post": { + "tags": [ + "notifications" + ], + "summary": "Mark one as read", + "description": "Set `read_at` on one of the caller's notifications.", + "operationId": "mark_read_api_v1_notifications__notification_id__read_post", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "notification_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Notification Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Notification" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/notifications/webhook": { + "get": { + "tags": [ + "notifications" + ], + "summary": "Get the organization webhook", + "description": "The organization's SIEM endpoint. `404` when none is configured.", + "operationId": "get_webhook_api_v1_notifications_webhook_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationWebhook" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + }, + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ] + }, + "put": { + "tags": [ + "notifications" + ], + "summary": "Create or replace the organization webhook", + "description": "Set the single webhook configuration for the organization.", + "operationId": "upsert_webhook_api_v1_notifications_webhook_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationWebhookUpsert" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationWebhook" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + }, + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ] + }, + "delete": { + "tags": [ + "notifications" + ], + "summary": "Disable the organization webhook", + "description": "Delete the configuration, which disables the integration.\n\nA `DELETE` rather than a revoke: nothing about the configuration needs to\nstay visible after it is switched off.", + "operationId": "delete_webhook_api_v1_notifications_webhook_delete", + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + }, + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ] + } + }, + "/api/v1/notifications/{notification_id}": { + "delete": { + "tags": [ + "notifications" + ], + "summary": "Dismiss a notification", + "description": "Permanently remove the caller's row.\n\nDismissal is deletion in v4.0 — there is no archived state, and no other user\nis affected because the row was never shared.", + "operationId": "dismiss_notification_api_v1_notifications__notification_id__delete", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "notification_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Notification Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/account": { + "get": { + "tags": [ + "account" + ], + "summary": "Get the current account", + "description": "The caller's `app_user` projection: identity, organization, role, preference.", + "operationId": "get_account_api_v1_account_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Account" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + }, + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ] + }, + "patch": { + "tags": [ + "account" + ], + "summary": "Update account preferences", + "description": "Change the email-notification opt-in.\n\nProfile fields are not editable here. They live in the identity provider and reach this\nprojection through claim updates.", + "operationId": "update_account_api_v1_account_patch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Account" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + }, + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ] + } + }, + "/api/v1/org/members": { + "get": { + "tags": [ + "organization" + ], + "summary": "List members", + "description": "Members of the caller's organization.", + "operationId": "list_members_api_v1_org_members_get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor returned as `next_cursor` by the previous page.", + "title": "Cursor" + }, + "description": "Opaque cursor returned as `next_cursor` by the previous page." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "description": "Max items per page.", + "default": 50, + "title": "Limit" + }, + "description": "Max items per page." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_Member_" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/org/members/{user_id}": { + "patch": { + "tags": [ + "organization" + ], + "summary": "Change a member's role", + "description": "Promote or demote a member.\n\nAnswers `409` when the change would leave no enabled administrator.", + "operationId": "update_member_role_api_v1_org_members__user_id__patch", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "User Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberRoleUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Member" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/org/members/{user_id}/disable": { + "post": { + "tags": [ + "organization" + ], + "summary": "Disable a member", + "description": "Revoke a member's access without removing them or their attribution.\n\nThere is no removal operation: disabling ends access, and erasure — a separate\nworkflow — removes the person.", + "operationId": "disable_member_api_v1_org_members__user_id__disable_post", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "User Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Member" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/org/members/{user_id}/enable": { + "post": { + "tags": [ + "organization" + ], + "summary": "Re-enable a member", + "description": "Restore access to a disabled member.", + "operationId": "enable_member_api_v1_org_members__user_id__enable_post", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "User Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Member" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/org/invitations": { + "get": { + "tags": [ + "organization" + ], + "summary": "List invitations", + "description": "Invitations issued by this organization, including spent and revoked ones.", + "operationId": "list_invitations_api_v1_org_invitations_get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor returned as `next_cursor` by the previous page.", + "title": "Cursor" + }, + "description": "Opaque cursor returned as `next_cursor` by the previous page." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "description": "Max items per page.", + "default": 50, + "title": "Limit" + }, + "description": "Max items per page." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_Invitation_" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + }, + "post": { + "tags": [ + "organization" + ], + "summary": "Invite someone to the organization", + "description": "Issue an invitation and email the link.\n\nAnswers `409` when a live invitation already exists for the same address. There\nis no resend: revoke the outstanding one and issue another, so every link ever\nsent has its own auditable row.", + "operationId": "create_invitation_api_v1_org_invitations_post", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/org/invitations/{invitation_id}": { + "delete": { + "tags": [ + "organization" + ], + "summary": "Revoke an invitation", + "description": "Invalidate the outstanding link.\n\nThe row survives with `revoked_at` set, which is why this returns the invitation\nrather than `204` — revocation changes state, it does not erase it.", + "operationId": "revoke_invitation_api_v1_org_invitations__invitation_id__delete", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "invitation_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Invitation Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/invitations/{token}": { + "get": { + "tags": [ + "organization" + ], + "summary": "Preview an invitation", + "description": "What the invitee sees before deciding.\n\nUnauthenticated, because the recipient may have no account yet. Answers `410`\nfor a spent, revoked, or expired token so the UI can say which.", + "operationId": "preview_invitation_api_v1_invitations__token__get", + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Token" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationPreview" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "410": { + "description": "Gone", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/invitations/{token}/acceptance": { + "post": { + "tags": [ + "organization" + ], + "summary": "Accept an invitation", + "description": "Join the organization.\n\nAtomic, and gated on three things at once: the token is live, the caller's\nverified email matches the invited address, and the caller does not already\nbelong to another organization. Failing any of them changes nothing.", + "operationId": "accept_invitation_api_v1_invitations__token__acceptance_post", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Token" + } + } + ], + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Member" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "410": { + "description": "Gone", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/api-keys": { + "get": { + "tags": [ + "api-keys" + ], + "summary": "List API keys", + "description": "Keys visible to the caller: their own, plus organization keys.\n\nRevoked keys stay listed. A key that once had access is part of the record of\nwho could reach what.", + "operationId": "list_api_keys_api_v1_api_keys_get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor returned as `next_cursor` by the previous page.", + "title": "Cursor" + }, + "description": "Opaque cursor returned as `next_cursor` by the previous page." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "description": "Max items per page.", + "default": 50, + "title": "Limit" + }, + "description": "Max items per page." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_ApiKey_" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + }, + "post": { + "tags": [ + "api-keys" + ], + "summary": "Create an API key", + "description": "Issue a key and return its secret once.\n\nRequires current MFA assurance. Creating an organization key additionally\nrequires the `organization_admin` role.", + "operationId": "create_api_key_api_v1_api_keys_post", + "security": [ + { + "OpenIdConnect": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyCreated" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/api-keys/{key_id}/revoke": { + "post": { + "tags": [ + "api-keys" + ], + "summary": "Revoke an API key", + "description": "Stop a key working while keeping its row.\n\nA `POST` rather than a `DELETE`, because `revoked_at` and the reason are the\npoint. Erasing an account also revokes and deletes that user's keys.", + "operationId": "revoke_api_key_api_v1_api_keys__key_id__revoke_post", + "security": [ + { + "OpenIdConnect": [] + } + ], + "parameters": [ + { + "name": "key_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid7", + "title": "Key Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyRevoke" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKey" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/statements": { + "get": { + "tags": [ + "statements" + ], + "summary": "List active statements", + "description": "Statements currently in force.\n\nUnauthenticated: a visitor has to be able to read the terms before there is an\naccount to attach an acceptance to.", + "operationId": "list_statements_api_v1_statements_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Statement" + }, + "type": "array", + "title": "Response List Statements Api V1 Statements Get" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/statement-responses": { + "post": { + "tags": [ + "statements" + ], + "summary": "Record an account-level response", + "description": "Record acceptance of an account-level statement.\n\nRejects any statement that requires a context: a per-launch declaration is bound\nto the launch it belongs to and travels in the launch payload, so recording one\nhere would produce a receipt attached to nothing.", + "operationId": "record_statement_response_api_v1_statement_responses_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatementResponseSubmission" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatementResponseReceipt" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + }, + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ] + } + }, + "/api/v1/admin/audit-events": { + "get": { + "tags": [ + "admin" + ], + "summary": "Read the audit log", + "description": "Audit entries in chain order.\n\nOrdered by (`chain_id`, `sequence_number`) rather than by time, because that\npair is what defines the hash chain — reading in timestamp order would not let\na verifier follow `previous_hash` from one entry to the next.\n\nEncrypted payloads are returned as ciphertext. Decryption is a separate operator\nprocedure and is not exposed by v4.0.", + "operationId": "list_audit_events_api_v1_admin_audit_events_get", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "parameters": [ + { + "name": "chain_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Restrict to one chain.", + "title": "Chain Id" + }, + "description": "Restrict to one chain." + }, + { + "name": "organization_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "description": "Restrict to one organization's chain.", + "title": "Organization Id" + }, + "description": "Restrict to one organization's chain." + }, + { + "name": "event_type", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Exact namespaced event type.", + "title": "Event Type" + }, + "description": "Exact namespaced event type." + }, + { + "name": "occurred_after", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "description": "Inclusive lower bound on `occurred_at`.", + "title": "Occurred After" + }, + "description": "Inclusive lower bound on `occurred_at`." + }, + { + "name": "occurred_before", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "description": "Exclusive upper bound on `occurred_at`.", + "title": "Occurred Before" + }, + "description": "Exclusive upper bound on `occurred_at`." + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor returned as `next_cursor` by the previous page.", + "title": "Cursor" + }, + "description": "Opaque cursor returned as `next_cursor` by the previous page." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "description": "Max items per page.", + "default": 50, + "title": "Limit" + }, + "description": "Max items per page." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_AuditEvent_" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/healthz": { + "get": { + "tags": [ + "health" + ], + "summary": "Liveness probe", + "description": "The process is up. Says nothing about dependencies.", + "operationId": "healthz_healthz_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthStatus" + } + } + } + } + } + } + }, + "/readyz": { + "get": { + "tags": [ + "health" + ], + "summary": "Readiness probe", + "description": "The process is ready to serve traffic.", + "operationId": "readyz_readyz_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthStatus" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Account": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "email": { + "type": "string", + "format": "email", + "title": "Email" + }, + "display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Display Name" + }, + "organization_id": { + "type": "string", + "format": "uuid7", + "title": "Organization Id" + }, + "organization_role": { + "$ref": "#/components/schemas/OrganizationRole", + "description": "Role within the organization. Platform-administrator status is a separate identity-provider claim and never appears here." + }, + "email_notifications_enabled": { + "type": "boolean", + "title": "Email Notifications Enabled" + } + }, + "type": "object", + "required": [ + "id", + "email", + "organization_id", + "organization_role", + "email_notifications_enabled" + ], + "title": "Account", + "description": "The caller's `app_user` projection.\n\nRead-only. Identity, credentials, display name, and email are owned by the identity provider\nand edited there; they arrive here through claim updates. The one field this\nplatform owns is the email-notification preference." + }, + "AccountUpdate": { + "properties": { + "email_notifications_enabled": { + "type": "boolean", + "title": "Email Notifications Enabled" + } + }, + "type": "object", + "required": [ + "email_notifications_enabled" + ], + "title": "AccountUpdate", + "description": "The only account field this API can change." + }, + "ApiKey": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "organization_id": { + "type": "string", + "format": "uuid7", + "title": "Organization Id" + }, + "owner_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Owner User Id", + "description": "The owning user, or null for an organization key. Organization keys require the `organization_admin` role to create." + }, + "created_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Created By User Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "scope": { + "$ref": "#/components/schemas/ApiKeyScope", + "description": "`read_only` permits `GET` operations. `full_scan` is additionally required to launch a scan." + }, + "key_prefix": { + "type": "string", + "title": "Key Prefix", + "description": "Non-secret prefix identifying the key in logs and in this list." + }, + "expires_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Expires At" + }, + "revoked_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Revoked At" + }, + "revocation_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Revocation Reason" + }, + "last_used_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Used At" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "organization_id", + "name", + "scope", + "key_prefix", + "created_at" + ], + "title": "ApiKey", + "description": "A key's metadata and lifecycle." + }, + "ApiKeyCreate": { + "properties": { + "name": { + "type": "string", + "minLength": 1, + "title": "Name", + "description": "Human label, so a key can be recognized later." + }, + "scope": { + "$ref": "#/components/schemas/ApiKeyScope" + }, + "organization_key": { + "type": "boolean", + "title": "Organization Key", + "description": "Issue a key owned by the organization rather than by the caller. Requires the `organization_admin` role.", + "default": false + }, + "expires_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Expires At", + "description": "Optional expiry. Absent means no expiry." + } + }, + "type": "object", + "required": [ + "name", + "scope" + ], + "title": "ApiKeyCreate", + "description": "Issue a key." + }, + "ApiKeyCreated": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "organization_id": { + "type": "string", + "format": "uuid7", + "title": "Organization Id" + }, + "owner_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Owner User Id", + "description": "The owning user, or null for an organization key. Organization keys require the `organization_admin` role to create." + }, + "created_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Created By User Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "scope": { + "$ref": "#/components/schemas/ApiKeyScope", + "description": "`read_only` permits `GET` operations. `full_scan` is additionally required to launch a scan." + }, + "key_prefix": { + "type": "string", + "title": "Key Prefix", + "description": "Non-secret prefix identifying the key in logs and in this list." + }, + "expires_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Expires At" + }, + "revoked_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Revoked At" + }, + "revocation_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Revocation Reason" + }, + "last_used_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Used At" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "secret": { + "type": "string", + "title": "Secret", + "description": "Plaintext key. Shown once; only a hash is stored.", + "examples": [ + "nc3_sk_live_7pL4vR8nT1mQ2xK9jH5gF3dS6aW0zY" + ] + } + }, + "type": "object", + "required": [ + "id", + "organization_id", + "name", + "scope", + "key_prefix", + "created_at", + "secret" + ], + "title": "ApiKeyCreated", + "description": "Creation response. The only place the secret ever appears." + }, + "ApiKeyRevoke": { + "properties": { + "revocation_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Revocation Reason", + "description": "Free-text note kept with the row for later investigation." + } + }, + "type": "object", + "title": "ApiKeyRevoke", + "description": "Revoke a key, optionally recording why." + }, + "ApiKeyScope": { + "type": "string", + "enum": [ + "read_only", + "full_scan" + ], + "title": "ApiKeyScope", + "description": "Capability granted by an API key." + }, + "Asset": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "organization_id": { + "type": "string", + "format": "uuid7", + "title": "Organization Id" + }, + "asset_type": { + "$ref": "#/components/schemas/AssetType" + }, + "value": { + "type": "string", + "title": "Value", + "description": "Canonical domain: lowercase IDNA (A-label) form without a trailing dot.", + "examples": [ + "example.lu" + ] + }, + "origin": { + "$ref": "#/components/schemas/AssetOrigin", + "description": "Whether a user registered this domain or discovery found it." + }, + "parent_asset_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Parent Asset Id", + "description": "The asset whose subdomain discovery produced this one." + }, + "created_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Created By User Id", + "description": "Attribution only. Null once the creating user is erased." + }, + "regression_alerts_enabled": { + "type": "boolean", + "title": "Regression Alerts Enabled", + "description": "Notify the organization when a resolved finding reappears.", + "default": false + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "organization_id", + "asset_type", + "value", + "origin", + "created_at", + "updated_at" + ], + "title": "Asset", + "description": "An organization-owned monitored domain." + }, + "AssetCreate": { + "properties": { + "value": { + "type": "string", + "title": "Value", + "description": "Domain to monitor. Unicode or ASCII input is accepted and canonicalized to lowercase IDNA (A-label) form without a trailing dot.", + "examples": [ + "example.lu" + ] + }, + "asset_type": { + "$ref": "#/components/schemas/AssetType", + "default": "domain" + } + }, + "type": "object", + "required": [ + "value" + ], + "title": "AssetCreate", + "description": "Register a domain to monitor." + }, + "AssetFeed": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "asset_id": { + "type": "string", + "format": "uuid7", + "title": "Asset Id" + }, + "format": { + "$ref": "#/components/schemas/FeedFormat" + }, + "created_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Created By User Id" + }, + "revoked_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Revoked At", + "description": "Set on revocation. The row is kept." + }, + "last_used_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Used At" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "asset_id", + "format", + "created_at" + ], + "title": "AssetFeed", + "description": "A per-asset syndication feed with a revocable token." + }, + "AssetFeedCreate": { + "properties": { + "format": { + "$ref": "#/components/schemas/FeedFormat" + } + }, + "type": "object", + "required": [ + "format" + ], + "title": "AssetFeedCreate", + "description": "Create a feed for an asset." + }, + "AssetFeedCreated": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "asset_id": { + "type": "string", + "format": "uuid7", + "title": "Asset Id" + }, + "format": { + "$ref": "#/components/schemas/FeedFormat" + }, + "created_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Created By User Id" + }, + "revoked_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Revoked At", + "description": "Set on revocation. The row is kept." + }, + "last_used_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Used At" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "token": { + "type": "string", + "title": "Token", + "description": "Plaintext feed token. Shown once." + }, + "feed_url": { + "type": "string", + "title": "Feed Url", + "description": "Fully-qualified URL to subscribe to. Shown once.", + "examples": [ + "https://api.testing.nc3.lu/api/v1/feeds/fd_9xK2mQ7pL4vR8nT1" + ] + } + }, + "type": "object", + "required": [ + "id", + "asset_id", + "format", + "created_at", + "token", + "feed_url" + ], + "title": "AssetFeedCreated", + "description": "Creation response. The only time the token is ever readable.\n\nOnly the hash is stored, so a lost token cannot be recovered — revoke the feed\nand create another." + }, + "AssetOrigin": { + "type": "string", + "enum": [ + "added", + "discovered" + ], + "title": "AssetOrigin", + "description": "Whether the asset was registered by a user or found by subdomain discovery." + }, + "AssetType": { + "type": "string", + "enum": [ + "domain" + ], + "title": "AssetType", + "description": "v4.0 assets are currently domains." + }, + "AssetUpdate": { + "properties": { + "regression_alerts_enabled": { + "type": "boolean", + "title": "Regression Alerts Enabled" + } + }, + "type": "object", + "required": [ + "regression_alerts_enabled" + ], + "title": "AssetUpdate", + "description": "Change the one mutable property of an asset.\n\n`value` and `asset_type` are immutable: a different domain is a different asset,\nwith its own scan history and its own ownership proof. Retargeting one in place\nwould silently reattribute both." + }, + "AuditEvent": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "organization_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Organization Id", + "description": "Null for platform-chain events." + }, + "chain_id": { + "type": "string", + "title": "Chain Id", + "description": "Organization or platform chain this entry belongs to. Never a per-user chain: chain membership would itself be a user identifier." + }, + "sequence_number": { + "type": "integer", + "title": "Sequence Number", + "description": "Position within the chain. Unique with `chain_id`." + }, + "event_type": { + "type": "string", + "title": "Event Type", + "description": "Namespaced event type. Vocabulary is code-owned.", + "examples": [ + "asset.verification.succeeded" + ] + }, + "subject_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Subject Type", + "description": "Resource category. Never identifies a user." + }, + "subject_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Subject Id", + "description": "Non-user resource this event concerns." + }, + "detail": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Detail", + "description": "Operational values only — status, counts, resource identifiers. Identity, email, addresses, and domains never appear here." + }, + "payload_encrypted": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Payload Encrypted", + "description": "Base64 ciphertext of the sensitive detail. Not decrypted by v4.0; returned so the hash chain can be verified." + }, + "wrapped_dek": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Wrapped Dek", + "description": "Base64 per-event key, wrapped by the user key." + }, + "envelope_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Envelope Id", + "description": "Opaque key-envelope reference. Carries no foreign key and encodes no user identifier; deleting the envelope is what shreds the payload." + }, + "encryption_metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Encryption Metadata", + "description": "Algorithm and nonce metadata for the payload." + }, + "occurred_at": { + "type": "string", + "format": "date-time", + "title": "Occurred At" + }, + "previous_hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Previous Hash", + "description": "Null for the first entry in a chain." + }, + "entry_hash": { + "type": "string", + "title": "Entry Hash", + "description": "Covers `previous_hash` and the whole stored entry, ciphertext and envelope reference included, so swapping either breaks the chain." + }, + "retention_until": { + "type": "string", + "format": "date-time", + "title": "Retention Until", + "description": "Twenty-four months after the event by default." + } + }, + "type": "object", + "required": [ + "id", + "chain_id", + "sequence_number", + "event_type", + "occurred_at", + "entry_hash", + "retention_until" + ], + "title": "AuditEvent", + "description": "One append-only audit entry." + }, + "DnsRecordType": { + "type": "string", + "enum": [ + "TXT" + ], + "title": "DnsRecordType", + "description": "DNS record a verification challenge is published as.\n\nTXT is the only v4.0 method." + }, + "DomainVerification": { + "properties": { + "asset_id": { + "type": "string", + "format": "uuid7", + "title": "Asset Id" + }, + "status": { + "$ref": "#/components/schemas/VerificationStatus", + "description": "`verified` whenever a proof exists, whatever the challenge is doing. `pending` while an unanswered challenge is still answerable. `expired` once it is no longer answerable and no proof exists." + }, + "verified_scope": { + "anyOf": [ + { + "$ref": "#/components/schemas/VerificationScope" + }, + { + "type": "null" + } + ], + "description": "Coverage actually proven, null until a challenge first succeeds. Requesting a wider scope does not widen this until that challenge succeeds, so an existing proof is never silently upgraded." + }, + "verified_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Verified At", + "description": "When the proof was recorded. Non-null exactly when `verified_scope` is." + }, + "challenge": { + "anyOf": [ + { + "$ref": "#/components/schemas/VerificationChallenge" + }, + { + "type": "null" + } + ], + "description": "The challenge currently running, or null when none is. It appears alongside `verified_scope` while an already-verified domain re-proves ownership or widens its coverage." + } + }, + "type": "object", + "required": [ + "asset_id", + "status" + ], + "title": "DomainVerification", + "description": "Current ownership state of one domain: the proof, plus any challenge running against it.\n\nThe proof and the challenge are independent.\nA domain that is already verified keeps `verified_scope` while a new challenge runs, so re-proving ownership or widening coverage never withdraws coverage that is already proven.\n\nOnly the current state lives here.\nAttempts and transitions are recorded in the audit log." + }, + "FeedFormat": { + "type": "string", + "enum": [ + "rss", + "atom" + ], + "title": "FeedFormat", + "description": "Syndication format of a per-asset feed." + }, + "FieldError": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Dotted path to the offending field, e.g. `body.email`." + }, + "reason": { + "type": "string", + "title": "Reason" + } + }, + "type": "object", + "required": [ + "name", + "reason" + ], + "title": "FieldError", + "description": "One field-level validation error (RFC 9457 extension member)." + }, + "Finding": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "scan_result_id": { + "type": "string", + "format": "uuid7", + "title": "Scan Result Id" + }, + "check_id": { + "type": "string", + "title": "Check Id", + "description": "Stable diagnostic-rule identifier. Regression matching keys on this and, where one rule yields several findings, on the normalized `affected_resource`." + }, + "severity": { + "$ref": "#/components/schemas/FindingSeverity" + }, + "status": { + "$ref": "#/components/schemas/FindingStatus", + "description": "Historical-comparison classification, derived when the result is written and immutable thereafter. No operation mutates it." + }, + "title": { + "type": "string", + "title": "Title" + }, + "description": { + "type": "string", + "title": "Description" + }, + "affected_resource": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Affected Resource", + "description": "The specific record, host, or header the finding concerns." + }, + "remediation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Remediation" + }, + "evidence": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Evidence", + "description": "Per-check evidence. Shape owned by the check." + }, + "external_references": { + "items": {}, + "type": "array", + "title": "External References", + "description": "External references for this rule. Element shape owned by the check." + } + }, + "type": "object", + "required": [ + "id", + "scan_result_id", + "check_id", + "severity", + "status", + "title", + "description" + ], + "title": "Finding", + "description": "One diagnostic-rule outcome recorded against a scan result.\n\n`check_id` is the stable identity anchor used to match a finding across scans.\nIt is never derived from the title or from position in the output, and changing\none is a breaking result-schema change." + }, + "FindingSeverity": { + "type": "string", + "enum": [ + "critical", + "high", + "medium", + "low", + "info" + ], + "title": "FindingSeverity", + "description": "Severity values of one finding." + }, + "FindingStatus": { + "type": "string", + "enum": [ + "new", + "regression", + "persistent", + "resolved" + ], + "title": "FindingStatus", + "description": "Historical-comparison classification, derived at result time and immutable." + }, + "HealthStatus": { + "properties": { + "status": { + "type": "string", + "title": "Status" + } + }, + "type": "object", + "required": [ + "status" + ], + "title": "HealthStatus", + "description": "Probe result." + }, + "Invitation": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "organization_id": { + "type": "string", + "format": "uuid7", + "title": "Organization Id" + }, + "email": { + "type": "string", + "format": "email", + "title": "Email" + }, + "organization_role": { + "$ref": "#/components/schemas/OrganizationRole" + }, + "invited_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Invited By User Id" + }, + "expires_at": { + "type": "string", + "format": "date-time", + "title": "Expires At" + }, + "accepted_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Accepted By User Id" + }, + "accepted_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Accepted At" + }, + "revoked_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Revoked At", + "description": "Set on revocation. The row is kept, so the attempt stays visible." + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "organization_id", + "email", + "organization_role", + "expires_at", + "created_at" + ], + "title": "Invitation", + "description": "An invitation's lifecycle, as the inviting organization sees it.\n\nThe token never appears here in any form." + }, + "InvitationCreate": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email" + }, + "organization_role": { + "$ref": "#/components/schemas/OrganizationRole", + "default": "member" + } + }, + "type": "object", + "required": [ + "email" + ], + "title": "InvitationCreate", + "description": "Invite one email address to join at one role." + }, + "InvitationPreview": { + "properties": { + "organization_name": { + "type": "string", + "title": "Organization Name" + }, + "organization_role": { + "$ref": "#/components/schemas/OrganizationRole" + }, + "email": { + "type": "string", + "format": "email", + "title": "Email", + "description": "The invited address. Acceptance requires a verified match." + }, + "expires_at": { + "type": "string", + "format": "date-time", + "title": "Expires At" + } + }, + "type": "object", + "required": [ + "organization_name", + "organization_role", + "email", + "expires_at" + ], + "title": "InvitationPreview", + "description": "What an invitee can see before accepting.\n\nDeliberately thin. Anyone holding the link can read this, so it carries the\norganization's name and nothing that would leak its membership or activity." + }, + "Member": { + "properties": { + "user_id": { + "type": "string", + "format": "uuid7", + "title": "User Id" + }, + "email": { + "type": "string", + "format": "email", + "title": "Email" + }, + "display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Display Name" + }, + "organization_role": { + "$ref": "#/components/schemas/OrganizationRole" + }, + "disabled_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Disabled At", + "description": "Set while the member is disabled. A disabled user cannot authenticate." + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "user_id", + "email", + "organization_role", + "created_at" + ], + "title": "Member", + "description": "One user's membership of the organization." + }, + "MemberRoleUpdate": { + "properties": { + "organization_role": { + "$ref": "#/components/schemas/OrganizationRole" + } + }, + "type": "object", + "required": [ + "organization_role" + ], + "title": "MemberRoleUpdate", + "description": "Change a member's role.\n\nRejected when it would remove the last enabled administrator — an organization\nthat cannot administer itself has no route back without operator intervention." + }, + "Notification": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "type": { + "type": "string", + "title": "Type", + "description": "Namespaced notification type. v4.0 covers verification completion, regressions, scan completion and failure, retention warnings, and token expiry. The vocabulary is code-owned and extends freely.", + "examples": [ + "scan.completed" + ] + }, + "schema_version": { + "type": "string", + "title": "Schema Version", + "description": "Version of this type's `data` shape.", + "examples": [ + "1.0" + ] + }, + "data": { + "additionalProperties": true, + "type": "object", + "title": "Data", + "description": "Type-specific payload." + }, + "read_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Read At", + "description": "Null while unread. There is no separate flag." + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "type", + "schema_version", + "created_at" + ], + "title": "Notification", + "description": "One inbox item belonging to one user." + }, + "OrganizationRole": { + "type": "string", + "enum": [ + "member", + "organization_admin" + ], + "title": "OrganizationRole", + "description": "Role within one organization." + }, + "OrganizationWebhook": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "organization_id": { + "type": "string", + "format": "uuid7", + "title": "Organization Id" + }, + "endpoint_url": { + "type": "string", + "minLength": 1, + "format": "uri", + "title": "Endpoint Url" + }, + "created_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Created By User Id" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "organization_id", + "endpoint_url", + "created_at", + "updated_at" + ], + "title": "OrganizationWebhook", + "description": "The organization's single SIEM integration endpoint.\n\nThe signing secret is never returned. It is set once on write and thereafter\nonly ever used to sign outgoing payloads." + }, + "OrganizationWebhookUpsert": { + "properties": { + "endpoint_url": { + "type": "string", + "minLength": 1, + "format": "uri", + "title": "Endpoint Url" + }, + "signing_secret": { + "type": "string", + "minLength": 32, + "title": "Signing Secret", + "description": "Shared secret used to sign delivered payloads. Stored encrypted and never returned. The payload's own `schema_version` belongs to the signed contract, not to this configuration." + } + }, + "type": "object", + "required": [ + "endpoint_url", + "signing_secret" + ], + "title": "OrganizationWebhookUpsert", + "description": "Create or replace the webhook configuration.\n\nA `PUT` rather than a `POST`, because an organization has zero or one of these" + }, + "Page_ApiKey_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ApiKey" + }, + "type": "array", + "title": "Items" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor", + "description": "Cursor for the next page; `null` when there are no more results." + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "Page[ApiKey]" + }, + "Page_Asset_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Asset" + }, + "type": "array", + "title": "Items" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor", + "description": "Cursor for the next page; `null` when there are no more results." + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "Page[Asset]" + }, + "Page_AuditEvent_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AuditEvent" + }, + "type": "array", + "title": "Items" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor", + "description": "Cursor for the next page; `null` when there are no more results." + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "Page[AuditEvent]" + }, + "Page_Finding_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Finding" + }, + "type": "array", + "title": "Items" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor", + "description": "Cursor for the next page; `null` when there are no more results." + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "Page[Finding]" + }, + "Page_Invitation_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Invitation" + }, + "type": "array", + "title": "Items" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor", + "description": "Cursor for the next page; `null` when there are no more results." + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "Page[Invitation]" + }, + "Page_Member_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Member" + }, + "type": "array", + "title": "Items" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor", + "description": "Cursor for the next page; `null` when there are no more results." + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "Page[Member]" + }, + "Page_Notification_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Notification" + }, + "type": "array", + "title": "Items" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor", + "description": "Cursor for the next page; `null` when there are no more results." + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "Page[Notification]" + }, + "Page_Report_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Report" + }, + "type": "array", + "title": "Items" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor", + "description": "Cursor for the next page; `null` when there are no more results." + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "Page[Report]" + }, + "Page_ScanJob_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ScanJob" + }, + "type": "array", + "title": "Items" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor", + "description": "Cursor for the next page; `null` when there are no more results." + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "Page[ScanJob]" + }, + "Page_Schedule_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Schedule" + }, + "type": "array", + "title": "Items" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor", + "description": "Cursor for the next page; `null` when there are no more results." + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "Page[Schedule]" + }, + "ProblemDetail": { + "properties": { + "type": { + "type": "string", + "title": "Type", + "description": "URI reference identifying the problem type.", + "default": "about:blank" + }, + "title": { + "type": "string", + "title": "Title", + "description": "Short, human-readable summary of the problem type." + }, + "status": { + "type": "integer", + "title": "Status", + "description": "HTTP status code." + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Detail", + "description": "Human-readable explanation for this occurrence." + }, + "instance": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Instance", + "description": "URI reference identifying this occurrence." + }, + "errors": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/FieldError" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Errors", + "description": "Field-level validation errors (extension)." + } + }, + "type": "object", + "required": [ + "title", + "status" + ], + "title": "ProblemDetail", + "description": "RFC 9457 problem detail." + }, + "Report": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "organization_id": { + "type": "string", + "format": "uuid7", + "title": "Organization Id" + }, + "tier": { + "$ref": "#/components/schemas/ReportTier" + }, + "technical_view": { + "anyOf": [ + { + "$ref": "#/components/schemas/TechnicalReportView" + }, + { + "type": "null" + } + ] + }, + "format": { + "$ref": "#/components/schemas/ReportFormat" + }, + "language": { + "$ref": "#/components/schemas/ReportLanguage" + }, + "source_scan_job_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Source Scan Job Id" + }, + "source_scan_task_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Source Scan Task Id" + }, + "generated_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Generated By User Id", + "description": "Attribution only. Null once the user is erased." + }, + "generated_at": { + "type": "string", + "format": "date-time", + "title": "Generated At" + } + }, + "type": "object", + "required": [ + "id", + "organization_id", + "tier", + "format", + "language", + "generated_at" + ], + "title": "Report", + "description": "Provenance metadata for one generated report.\n\nThe source identifiers deliberately carry no foreign key." + }, + "ReportFormat": { + "type": "string", + "enum": [ + "pdf", + "docx", + "json" + ], + "title": "ReportFormat", + "description": "Rendered report document formats." + }, + "ReportLanguage": { + "type": "string", + "enum": [ + "en", + "fr", + "de" + ], + "title": "ReportLanguage", + "description": "Report output language." + }, + "ReportRequest": { + "properties": { + "tier": { + "$ref": "#/components/schemas/ReportTier" + }, + "technical_view": { + "anyOf": [ + { + "$ref": "#/components/schemas/TechnicalReportView" + }, + { + "type": "null" + } + ], + "description": "Depth of a technical report. Meaningless for the executive tier." + }, + "format": { + "$ref": "#/components/schemas/ReportFormat" + }, + "language": { + "$ref": "#/components/schemas/ReportLanguage", + "default": "en" + }, + "source_scan_job_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Source Scan Job Id", + "description": "Report on a whole scan. Mutually exclusive." + }, + "source_scan_task_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Source Scan Task Id", + "description": "Report on one test's result. Mutually exclusive." + } + }, + "type": "object", + "required": [ + "tier", + "format" + ], + "title": "ReportRequest", + "description": "Generate one report from exactly one source." + }, + "ReportTier": { + "type": "string", + "enum": [ + "executive", + "technical" + ], + "title": "ReportTier", + "description": "Audience of a generated report." + }, + "ResultTrend": { + "properties": { + "previous_scan_result_id": { + "type": "string", + "format": "uuid7", + "title": "Previous Scan Result Id", + "description": "The result this one was compared against." + }, + "direction": { + "$ref": "#/components/schemas/TrendDirection" + }, + "delta": { + "type": "number", + "title": "Delta", + "description": "Signed change, positive when improving. Grades move in whole steps along `A+ A B C D F`; severity counts move by finding count." + } + }, + "type": "object", + "required": [ + "previous_scan_result_id", + "direction", + "delta" + ], + "title": "ResultTrend", + "description": "Movement of a result's score against the previous result for the same test.\n\nComputed per request from the two results. `delta` uses the scale of whichever\nmetric was compared, so it is comparable against other deltas for the same test." + }, + "ScanClaimRequest": { + "properties": { + "claim_token": { + "type": "string", + "title": "Claim Token", + "description": "The one-time token returned by the unauthenticated launch.", + "examples": [ + "9xK2mQ7pL4vR8nT1jH5gF3dS6aW0zYbUcElOnAiKrXs" + ] + } + }, + "type": "object", + "required": [ + "claim_token" + ], + "title": "ScanClaimRequest", + "description": "Claims a guest scan for the authenticated caller's organization.\n\nThe token travels in the body rather than a header: it authorizes one operation\non one resource, not the caller, and `Authorization` already carries the session\nthat identifies who is claiming." + }, + "ScanClassification": { + "type": "string", + "enum": [ + "non_intrusive", + "intrusive", + "not_applicable" + ], + "title": "ScanClassification", + "description": "Intrusiveness of one executable test, copied onto the task at creation.\n\n`not_applicable` is used only by File tests." + }, + "ScanGrade": { + "type": "string", + "enum": [ + "A+", + "A", + "B", + "C", + "D", + "F" + ], + "title": "ScanGrade", + "description": "Letter grade. Produced by the scan modules." + }, + "ScanJob": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "organization_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Organization Id", + "description": "Owning organization, and the row-level-security key. Null for a guest job until it is claimed. Tasks, results, and findings inherit it, so it is not repeated on those resources." + }, + "source": { + "$ref": "#/components/schemas/ScanSource", + "description": "Derived server-side from the request context, never supplied by the caller. It selects which gates apply." + }, + "schedule_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Schedule Id", + "description": "Present when `source` is `schedule`." + }, + "api_key_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Api Key Id", + "description": "Present when `source` is `api`." + }, + "triggered_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Triggered By User Id", + "description": "Attribution only. Becomes null if the user is erased." + }, + "asset_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Asset Id" + }, + "target_domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Domain", + "description": "Canonical domain that is not an Asset row. Populated only by an unauthenticated launch; a guest target never becomes an Asset." + }, + "file_upload_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "File Upload Id", + "description": "The upload created by an accepted multipart launch. At most one per job." + }, + "modules": { + "items": { + "$ref": "#/components/schemas/ScanModule" + }, + "type": "array", + "title": "Modules", + "description": "What the launch asked for. Compare against the tasks to see what ran: a requested module whose task was blocked or skipped produced nothing." + }, + "module_configuration": { + "additionalProperties": true, + "type": "object", + "title": "Module Configuration" + }, + "status": { + "$ref": "#/components/schemas/ScanJobStatus" + }, + "status_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status Reason", + "description": "Stable namespaced reason code for a job-wide exceptional or terminal outcome. A job timeout sets the job-timeout reason and resolves to `partial` when usable results exist, otherwise `failed`." + }, + "claimed_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Claimed By User Id" + }, + "claimed_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Claimed At" + }, + "purge_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Purge At", + "description": "Read-only final hard-deletion timestamp, not the start of a grace period. Null until terminal completion, then `finished_at` plus twelve months plus thirty days by default, with thirty days' notice.\n\nAn unclaimed guest job is the exception: it carries a deadline from creation, because ownerless data has nobody to notify and no reason to be kept. A successful claim recomputes this under the normal rule, and notice begins to apply only from that point. Purging at the deadline does not wait for the job to finish; unfinished work is terminated." + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "started_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Started At" + }, + "finished_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Finished At" + } + }, + "type": "object", + "required": [ + "id", + "source", + "modules", + "status", + "created_at" + ], + "title": "ScanJob", + "description": "One submitted scan request.\n\nExactly one of `asset_id`, `target_domain`, and `file_upload_id` is set, chosen\nby the request context rather than by the caller: an authenticated JSON launch\npopulates `asset_id`, an unauthenticated JSON launch populates `target_domain`,\nand a multipart launch populates `file_upload_id`." + }, + "ScanJobAccepted": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "organization_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Organization Id", + "description": "Owning organization, and the row-level-security key. Null for a guest job until it is claimed. Tasks, results, and findings inherit it, so it is not repeated on those resources." + }, + "source": { + "$ref": "#/components/schemas/ScanSource", + "description": "Derived server-side from the request context, never supplied by the caller. It selects which gates apply." + }, + "schedule_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Schedule Id", + "description": "Present when `source` is `schedule`." + }, + "api_key_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Api Key Id", + "description": "Present when `source` is `api`." + }, + "triggered_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Triggered By User Id", + "description": "Attribution only. Becomes null if the user is erased." + }, + "asset_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Asset Id" + }, + "target_domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Domain", + "description": "Canonical domain that is not an Asset row. Populated only by an unauthenticated launch; a guest target never becomes an Asset." + }, + "file_upload_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "File Upload Id", + "description": "The upload created by an accepted multipart launch. At most one per job." + }, + "modules": { + "items": { + "$ref": "#/components/schemas/ScanModule" + }, + "type": "array", + "title": "Modules", + "description": "What the launch asked for. Compare against the tasks to see what ran: a requested module whose task was blocked or skipped produced nothing." + }, + "module_configuration": { + "additionalProperties": true, + "type": "object", + "title": "Module Configuration" + }, + "status": { + "$ref": "#/components/schemas/ScanJobStatus" + }, + "status_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status Reason", + "description": "Stable namespaced reason code for a job-wide exceptional or terminal outcome. A job timeout sets the job-timeout reason and resolves to `partial` when usable results exist, otherwise `failed`." + }, + "claimed_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Claimed By User Id" + }, + "claimed_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Claimed At" + }, + "purge_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Purge At", + "description": "Read-only final hard-deletion timestamp, not the start of a grace period. Null until terminal completion, then `finished_at` plus twelve months plus thirty days by default, with thirty days' notice.\n\nAn unclaimed guest job is the exception: it carries a deadline from creation, because ownerless data has nobody to notify and no reason to be kept. A successful claim recomputes this under the normal rule, and notice begins to apply only from that point. Purging at the deadline does not wait for the job to finish; unfinished work is terminated." + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "started_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Started At" + }, + "finished_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Finished At" + }, + "claim_token": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Claim Token", + "description": "One-time token that claims this scan for an organization once the guest registers. Present only on the response to an unauthenticated launch, and readable only here — the server keeps a hash, so a lost token cannot be recovered and the scan stays unclaimable.", + "examples": [ + "9xK2mQ7pL4vR8nT1jH5gF3dS6aW0zYbUcElOnAiKrXs" + ] + } + }, + "type": "object", + "required": [ + "id", + "source", + "modules", + "status", + "created_at" + ], + "title": "ScanJobAccepted", + "description": "The `202` body of a launch.\n\nIdentical to a ScanJob except that an unauthenticated launch also returns the\none-time token needed to claim the scan after registering." + }, + "ScanJobDetail": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "organization_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Organization Id", + "description": "Owning organization, and the row-level-security key. Null for a guest job until it is claimed. Tasks, results, and findings inherit it, so it is not repeated on those resources." + }, + "source": { + "$ref": "#/components/schemas/ScanSource", + "description": "Derived server-side from the request context, never supplied by the caller. It selects which gates apply." + }, + "schedule_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Schedule Id", + "description": "Present when `source` is `schedule`." + }, + "api_key_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Api Key Id", + "description": "Present when `source` is `api`." + }, + "triggered_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Triggered By User Id", + "description": "Attribution only. Becomes null if the user is erased." + }, + "asset_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Asset Id" + }, + "target_domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Domain", + "description": "Canonical domain that is not an Asset row. Populated only by an unauthenticated launch; a guest target never becomes an Asset." + }, + "file_upload_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "File Upload Id", + "description": "The upload created by an accepted multipart launch. At most one per job." + }, + "modules": { + "items": { + "$ref": "#/components/schemas/ScanModule" + }, + "type": "array", + "title": "Modules", + "description": "What the launch asked for. Compare against the tasks to see what ran: a requested module whose task was blocked or skipped produced nothing." + }, + "module_configuration": { + "additionalProperties": true, + "type": "object", + "title": "Module Configuration" + }, + "status": { + "$ref": "#/components/schemas/ScanJobStatus" + }, + "status_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status Reason", + "description": "Stable namespaced reason code for a job-wide exceptional or terminal outcome. A job timeout sets the job-timeout reason and resolves to `partial` when usable results exist, otherwise `failed`." + }, + "claimed_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Claimed By User Id" + }, + "claimed_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Claimed At" + }, + "purge_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Purge At", + "description": "Read-only final hard-deletion timestamp, not the start of a grace period. Null until terminal completion, then `finished_at` plus twelve months plus thirty days by default, with thirty days' notice.\n\nAn unclaimed guest job is the exception: it carries a deadline from creation, because ownerless data has nobody to notify and no reason to be kept. A successful claim recomputes this under the normal rule, and notice begins to apply only from that point. Purging at the deadline does not wait for the job to finish; unfinished work is terminated." + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "started_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Started At" + }, + "finished_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Finished At" + }, + "tasks": { + "items": { + "$ref": "#/components/schemas/ScanTask" + }, + "type": "array", + "title": "Tasks" + } + }, + "type": "object", + "required": [ + "id", + "source", + "modules", + "status", + "created_at" + ], + "title": "ScanJobDetail", + "description": "A job together with its task snapshot.\n\nThis is the snapshot a live-progress client fetches before subscribing, and\nrefetches after a reconnect. Results are a separate call because they are large." + }, + "ScanJobStatus": { + "type": "string", + "enum": [ + "queued", + "running", + "completed", + "partial", + "failed", + "canceled" + ], + "title": "ScanJobStatus", + "description": "Job lifecycle. `partial` means usable results exist alongside failures." + }, + "ScanModule": { + "type": "string", + "enum": [ + "email", + "web", + "file", + "pqc", + "dnssec" + ], + "title": "ScanModule", + "description": "The five v4.0 test modules." + }, + "ScanResult": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "scan_task_id": { + "type": "string", + "format": "uuid7", + "title": "Scan Task Id" + }, + "schema_version": { + "type": "string", + "title": "Schema Version", + "description": "Version of the result payload written by this test. Text, not a number: the registry versions each test's result schema independently." + }, + "raw_output": { + "additionalProperties": true, + "type": "object", + "title": "Raw Output", + "description": "Full test output. Shape owned by the executable-test registry." + }, + "summary": { + "additionalProperties": true, + "type": "object", + "title": "Summary", + "description": "Condensed verdicts for display. Non-graded tests carry their per-step verdicts here. Shape owned by the executable-test registry." + }, + "grade": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScanGrade" + }, + { + "type": "null" + } + ], + "description": "Letter grade. Present only for `email.mailvalidator`, `web.headers`, and `web.tls`. No cross-module composite score exists." + }, + "severity_counts": { + "anyOf": [ + { + "$ref": "#/components/schemas/SeverityCounts" + }, + { + "type": "null" + } + ], + "description": "Findings by severity. Used by non-graded tests." + }, + "trend": { + "anyOf": [ + { + "$ref": "#/components/schemas/ResultTrend" + }, + { + "type": "null" + } + ], + "description": "Movement against the previous result for this test, tracking `grade` where the test is graded and total findings where it is not. Null on the first result for a test, or once the predecessor has been purged." + }, + "completed_at": { + "type": "string", + "format": "date-time", + "title": "Completed At" + } + }, + "type": "object", + "required": [ + "id", + "scan_task_id", + "schema_version", + "raw_output", + "completed_at" + ], + "title": "ScanResult", + "description": "The output of one completed ScanTask. At most one per task." + }, + "ScanSource": { + "type": "string", + "enum": [ + "guest", + "manual", + "schedule", + "api" + ], + "title": "ScanSource", + "description": "What requested a scan job.\n\nDerived from the request context, never supplied by the caller." + }, + "ScanTask": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "scan_job_id": { + "type": "string", + "format": "uuid7", + "title": "Scan Job Id" + }, + "parent_task_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Parent Task Id", + "description": "Discovery and fan-out lineage. A subdomain found by `web.subdomain_enumeration` becomes a child task of the discovering one." + }, + "module": { + "$ref": "#/components/schemas/ScanModule" + }, + "test_key": { + "type": "string", + "title": "Test Key", + "description": "Stable identifier of the executable test. v4.0 catalog: `email.mailvalidator`, `web.headers`, `web.tls`, `web.subdomain_enumeration`, `file.hashlookup`, `file.pandora`, `file.metadata`, `file.mime_check`, `pqc.quantumvalidator`, `dnssec.chainvalidator`. The vocabulary is code-owned and extends without a schema change." + }, + "test_version": { + "type": "string", + "title": "Test Version", + "description": "Version of the test definition, copied at task creation." + }, + "classification": { + "$ref": "#/components/schemas/ScanClassification" + }, + "target_asset_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Target Asset Id" + }, + "target_domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Domain", + "description": "Set for a guest target or a discovered subdomain with no Asset row." + }, + "file_upload_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "File Upload Id" + }, + "configuration": { + "additionalProperties": true, + "type": "object", + "title": "Configuration", + "description": "Resolved per-test configuration. Shape owned by the test registry." + }, + "status": { + "$ref": "#/components/schemas/ScanTaskStatus" + }, + "status_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status Reason", + "description": "Stable namespaced reason code for a failed, skipped, blocked, or canceled outcome. Always present when the status is `blocked`. A task timeout appears as `failed` plus the task-timeout reason: timeout is a reason, never a status. Labels and localization are code-owned." + }, + "cancellation_requested_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Cancellation Requested At", + "description": "Durable cancellation intent. Workers check it before starting and at safe interruption points; a canceled task cannot later produce an accepted successful result." + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "started_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Started At" + }, + "finished_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Finished At" + } + }, + "type": "object", + "required": [ + "id", + "scan_job_id", + "module", + "test_key", + "test_version", + "classification", + "status", + "created_at" + ], + "title": "ScanTask", + "description": "One executable test run against one domain or one uploaded file.\n\nExactly one of `target_asset_id`, `target_domain`, and `file_upload_id` is set.\n`id` doubles as the queue task identifier and as the public `task_id` carried by\nlive-progress events." + }, + "ScanTaskStatus": { + "type": "string", + "enum": [ + "queued", + "running", + "completed", + "failed", + "skipped", + "blocked", + "canceled" + ], + "title": "ScanTaskStatus", + "description": "Task lifecycle.\n\n`blocked` always carries a `status_reason` explaining why,\nso the UI can tell the user what stopped a check." + }, + "Schedule": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "organization_id": { + "type": "string", + "format": "uuid7", + "title": "Organization Id" + }, + "asset_id": { + "type": "string", + "format": "uuid7", + "title": "Asset Id" + }, + "created_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Created By User Id", + "description": "Attribution only. The schedule is organization-owned." + }, + "modules": { + "items": { + "$ref": "#/components/schemas/ScanModule" + }, + "type": "array", + "title": "Modules" + }, + "module_configuration": { + "additionalProperties": true, + "type": "object", + "title": "Module Configuration" + }, + "recurrence_rule": { + "type": "string", + "title": "Recurrence Rule", + "description": "RFC 5545 RRULE, without the `RRULE:` prefix, e.g. `FREQ=WEEKLY;BYDAY=MO;BYHOUR=2;BYMINUTE=0`." + }, + "timezone": { + "type": "string", + "title": "Timezone", + "description": "IANA timezone the rule is evaluated in, e.g. `Europe/Luxembourg`. Stored separately from the rule so local run times survive daylight-saving changes." + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true + }, + "next_run_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Next Run At", + "description": "Next fire time. Null while disabled or once the rule is exhausted." + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "organization_id", + "asset_id", + "modules", + "recurrence_rule", + "timezone", + "created_at", + "updated_at" + ], + "title": "Schedule", + "description": "A recurring scan of one asset." + }, + "ScheduleCreate": { + "properties": { + "asset_id": { + "type": "string", + "format": "uuid7", + "title": "Asset Id" + }, + "modules": { + "items": { + "$ref": "#/components/schemas/ScanModule" + }, + "type": "array", + "minItems": 1, + "title": "Modules" + }, + "module_configuration": { + "additionalProperties": true, + "type": "object", + "title": "Module Configuration" + }, + "recurrence_rule": { + "type": "string", + "title": "Recurrence Rule", + "description": "RFC 5545 RRULE, without the `RRULE:` prefix, e.g. `FREQ=WEEKLY;BYDAY=MO;BYHOUR=2;BYMINUTE=0`." + }, + "timezone": { + "type": "string", + "title": "Timezone", + "description": "IANA timezone the rule is evaluated in, e.g. `Europe/Luxembourg`. Stored separately from the rule so local run times survive daylight-saving changes." + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true + } + }, + "type": "object", + "required": [ + "asset_id", + "modules", + "recurrence_rule", + "timezone" + ], + "title": "ScheduleCreate", + "description": "Create a recurring scan.\n\nThe asset must already be eligible under the verification rules. Eligibility is\nrechecked at every execution rather than trusted from creation time, so a\nverification that lapses stops producing scans instead of quietly continuing." + }, + "ScheduleUpdate": { + "properties": { + "modules": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ScanModule" + }, + "type": "array", + "minItems": 1 + }, + { + "type": "null" + } + ], + "title": "Modules" + }, + "module_configuration": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Module Configuration" + }, + "recurrence_rule": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Recurrence Rule", + "description": "RFC 5545 RRULE, without the `RRULE:` prefix, e.g. `FREQ=WEEKLY;BYDAY=MO;BYHOUR=2;BYMINUTE=0`." + }, + "timezone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Timezone", + "description": "IANA timezone the rule is evaluated in, e.g. `Europe/Luxembourg`. Stored separately from the rule so local run times survive daylight-saving changes." + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enabled" + } + }, + "type": "object", + "title": "ScheduleUpdate", + "description": "Partial update. Omitted fields are left alone.\n\n`asset_id` is absent: repointing a schedule at another asset would attribute one\nasset's recurring history to a different one." + }, + "SeverityCounts": { + "properties": { + "critical": { + "type": "integer", + "minimum": 0.0, + "title": "Critical", + "description": "Findings in the critical band.", + "default": 0 + }, + "high": { + "type": "integer", + "minimum": 0.0, + "title": "High", + "description": "Findings in the high band.", + "default": 0 + }, + "medium": { + "type": "integer", + "minimum": 0.0, + "title": "Medium", + "description": "Findings in the medium band.", + "default": 0 + }, + "low": { + "type": "integer", + "minimum": 0.0, + "title": "Low", + "description": "Findings in the low band.", + "default": 0 + }, + "info": { + "type": "integer", + "minimum": 0.0, + "title": "Info", + "description": "Findings in the informational band.", + "default": 0 + } + }, + "type": "object", + "title": "SeverityCounts", + "description": "Findings counted by severity band.\n\nStored as untyped JSONB in `scan_result.severity_counts`, but the key set is fully determined by `finding_severity`, so the contract types it.\nNon-graded tests summarize outcomes through these counts instead of a letter grade; graded tests carry both." + }, + "Statement": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "statement_key": { + "type": "string", + "title": "Statement Key" + }, + "version": { + "type": "string", + "title": "Version" + }, + "response_kind": { + "$ref": "#/components/schemas/StatementResponseKind" + }, + "required_context_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Required Context Type", + "description": "Null for an account-level statement. `scan_job` for a per-launch one, whose response must be bound to the launch it belongs to." + }, + "content_hash": { + "type": "string", + "title": "Content Hash", + "description": "Hash of the exact text, so a receipt can prove what was shown." + }, + "content_uri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Content Uri" + }, + "effective_at": { + "type": "string", + "format": "date-time", + "title": "Effective At" + } + }, + "type": "object", + "required": [ + "id", + "statement_key", + "version", + "response_kind", + "content_hash", + "effective_at" + ], + "title": "Statement", + "description": "One currently active versioned statement.\n\nActive means `effective_at` has passed and the statement is not retired." + }, + "StatementResponseKind": { + "type": "string", + "enum": [ + "acceptance", + "attestation" + ], + "title": "StatementResponseKind", + "description": "Acceptance and attestation share a receipt shape but are distinct acts." + }, + "StatementResponseReceipt": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "statement_id": { + "type": "string", + "format": "uuid7", + "title": "Statement Id" + }, + "responded_at": { + "type": "string", + "format": "date-time", + "title": "Responded At" + }, + "context_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Context Type", + "description": "Null for an account-level response." + }, + "context_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Context Id", + "description": "The bound resource — a ScanJob for a per-launch response." + } + }, + "type": "object", + "required": [ + "id", + "statement_id", + "responded_at" + ], + "title": "StatementResponseReceipt", + "description": "Proof that a statement was answered.\n\nImmutable: a correction is a new statement version and a new response, never an\nedit. Actor evidence is deliberately absent from this representation." + }, + "StatementResponseSubmission": { + "properties": { + "statement_key": { + "type": "string", + "title": "Statement Key", + "description": "Namespaced statement identifier, e.g. `scan_target_permission`.", + "examples": [ + "terms_and_conditions" + ] + }, + "version": { + "type": "string", + "title": "Version", + "description": "Exact version answered, as returned by `GET /statements`.", + "examples": [ + "2026-01-15" + ] + } + }, + "type": "object", + "required": [ + "statement_key", + "version" + ], + "title": "StatementResponseSubmission", + "description": "A client's answer to one statement, identified by key and version.\n\nThe version is explicit, so the receipt records the exact text that was shown,\nnot whichever version happened to be current." + }, + "TechnicalReportView": { + "type": "string", + "enum": [ + "full", + "summary" + ], + "title": "TechnicalReportView", + "description": "Depth of a technical report. Meaningful only when the evidence tier is technical." + }, + "TrendDirection": { + "type": "string", + "enum": [ + "improving", + "unchanged", + "declining" + ], + "title": "TrendDirection", + "description": "Movement of a score against the previous comparable measurement." + }, + "VerificationChallenge": { + "properties": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "requested_scope": { + "$ref": "#/components/schemas/VerificationScope", + "description": "Coverage this challenge proves if it succeeds." + }, + "record_type": { + "$ref": "#/components/schemas/DnsRecordType", + "description": "Type of DNS record to create.", + "default": "TXT" + }, + "record_name": { + "type": "string", + "title": "Record Name", + "description": "Name of the DNS record to create. Computed by the server from the asset's domain and a deployment-configured vendor prefix. Display the returned value; do not rebuild it.", + "examples": [ + "_nc3-verify.example.lu" + ] + }, + "verification_token": { + "type": "string", + "title": "Verification Token", + "description": "Complete value of the record, pasted verbatim. The client does not wrap, prefix, or encode it.", + "examples": [ + "verify-4f7a2c9e1b8d3056" + ] + }, + "token_expires_at": { + "type": "string", + "format": "date-time", + "title": "Token Expires At", + "description": "When the challenge stops being answerable. Seven days from issue by default. Reaching it retires the challenge; a proof already recorded is unaffected." + }, + "requested_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid7" + }, + { + "type": "null" + } + ], + "title": "Requested By User Id" + }, + "requested_at": { + "type": "string", + "format": "date-time", + "title": "Requested At" + }, + "last_recheck_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Recheck At", + "description": "When the record was last looked for, whatever triggered the lookup — a user pressing verify, or the recheck that runs before an intrusive task is queued. Null until the first check. Pair it with `failure_code` to show why the last attempt did not succeed." + }, + "failure_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Failure Code", + "description": "Stable namespaced reason the last check did not succeed." + } + }, + "type": "object", + "required": [ + "id", + "requested_scope", + "record_name", + "verification_token", + "token_expires_at", + "requested_at" + ], + "title": "VerificationChallenge", + "description": "A DNS record to publish to prove control of the domain.\n\nPresent while a challenge is answerable, and while its last check is being shown.\nAbsent once the challenge has produced its proof and absent on a domain with no challenge running." + }, + "VerificationCreate": { + "properties": { + "requested_scope": { + "$ref": "#/components/schemas/VerificationScope", + "description": "`exact` covers this domain alone. `zone` covers it and everything beneath it, evaluated by DNS-label ancestry rather than string suffix, so `evil-example.lu` is never treated as part of `example.lu`." + } + }, + "type": "object", + "required": [ + "requested_scope" + ], + "title": "VerificationCreate", + "description": "Start a domain-ownership challenge at a chosen coverage." + }, + "VerificationScope": { + "type": "string", + "enum": [ + "exact", + "zone" + ], + "title": "VerificationScope", + "description": "Verification coverage. Zone coverage is evaluated by DNS-label ancestry." + }, + "VerificationStatus": { + "type": "string", + "enum": [ + "pending", + "verified", + "expired" + ], + "title": "VerificationStatus", + "description": "Current state of a domain-ownership challenge." + }, + "AssetScanLaunch": { + "description": "Authenticated domain launch.\n\nSelected by `application/json` plus an authenticated caller.", + "properties": { + "modules": { + "description": "One or more modules to run against the target.", + "items": { + "$ref": "#/components/schemas/ScanModule" + }, + "minItems": 1, + "title": "Modules", + "type": "array" + }, + "module_configuration": { + "additionalProperties": true, + "description": "Per-module launch options. Each module defines its own option shape; the web module's subdomain-discovery option is one example.", + "title": "Module Configuration", + "type": "object" + }, + "statement_responses": { + "description": "Answers to the statements this launch requires, each identified by key and version. Recorded as immutable receipts bound to the job before any gate is evaluated. Empty in practice for v4.0: a declaration is required only by an intrusive test, and the v4.0 catalog classifies none. A boolean `attestation` flag is not a substitute and is not part of this contract.", + "items": { + "$ref": "#/components/schemas/StatementResponseSubmission" + }, + "title": "Statement Responses", + "type": "array" + }, + "asset_id": { + "description": "An Asset belonging to the caller's organization.", + "format": "uuid7", + "title": "Asset Id", + "type": "string" + } + }, + "required": [ + "modules", + "asset_id" + ], + "title": "AssetScanLaunch", + "type": "object" + }, + "FileScanLaunch": { + "description": "File launch. Selected by `multipart/form-data`.\n\nCarries no target field and no `modules` field.\nThe resulting job is always the File module.", + "properties": { + "file": { + "contentMediaType": "application/octet-stream", + "description": "The file to analyze. Maximum 50 MB by default. The MIME type is detected from the raw bytes; the declared `Content-Type` and the filename extension are not trusted.", + "title": "File", + "type": "string" + }, + "module_configuration": { + "additionalProperties": true, + "title": "Module Configuration", + "type": "object" + } + }, + "required": [ + "file" + ], + "title": "FileScanLaunch", + "type": "object" + }, + "GuestScanLaunch": { + "description": "Unauthenticated domain launch.\n\nSelected by `application/json` plus an anonymous caller.\n\nFree target text exists in this context and nowhere else. The domain is stored\non the job and never becomes an Asset row. Guest launches run non-intrusive\ntests only, which in v4.0 is every domain test.", + "properties": { + "modules": { + "description": "One or more modules to run against the target.", + "items": { + "$ref": "#/components/schemas/ScanModule" + }, + "minItems": 1, + "title": "Modules", + "type": "array" + }, + "module_configuration": { + "additionalProperties": true, + "description": "Per-module launch options. Each module defines its own option shape; the web module's subdomain-discovery option is one example.", + "title": "Module Configuration", + "type": "object" + }, + "statement_responses": { + "description": "Answers to the statements this launch requires, each identified by key and version. Recorded as immutable receipts bound to the job before any gate is evaluated. Empty in practice for v4.0: a declaration is required only by an intrusive test, and the v4.0 catalog classifies none. A boolean `attestation` flag is not a substitute and is not part of this contract.", + "items": { + "$ref": "#/components/schemas/StatementResponseSubmission" + }, + "title": "Statement Responses", + "type": "array" + }, + "target": { + "description": "Domain to scan. Unicode or ASCII input is accepted and canonicalized to lowercase IDNA (A-label) form without a trailing dot.", + "examples": [ + "example.lu" + ], + "title": "Target", + "type": "string" + } + }, + "required": [ + "modules", + "target" + ], + "title": "GuestScanLaunch", + "type": "object" + }, + "ScanEndEvent": { + "description": "SSE `end`: the job reached a terminal state and no further events follow.", + "properties": { + "status": { + "$ref": "#/components/schemas/ScanJobStatus" + }, + "occurred_at": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + } + }, + "required": [ + "status", + "occurred_at" + ], + "title": "ScanEndEvent", + "type": "object" + }, + "ScanHeartbeatEvent": { + "description": "SSE `heartbeat`: the stream is alive.\n\nSent on an interval so a client can tell a running scan from a dropped\nconnection, and show when it last heard anything.", + "properties": { + "occurred_at": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + } + }, + "required": [ + "occurred_at" + ], + "title": "ScanHeartbeatEvent", + "type": "object" + }, + "ScanJobEvent": { + "description": "SSE `job`: the job changed state.", + "properties": { + "status": { + "$ref": "#/components/schemas/ScanJobStatus" + }, + "status_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status Reason" + }, + "occurred_at": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + } + }, + "required": [ + "status", + "occurred_at" + ], + "title": "ScanJobEvent", + "type": "object" + }, + "ScanTaskEvent": { + "description": "SSE `task`: one task changed state.", + "properties": { + "task_id": { + "format": "uuid7", + "title": "Task Id", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ScanTaskStatus" + }, + "status_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Present on a terminal status.", + "title": "Status Reason" + }, + "occurred_at": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + } + }, + "required": [ + "task_id", + "status", + "occurred_at" + ], + "title": "ScanTaskEvent", + "type": "object" + } + }, + "securitySchemes": { + "OpenIdConnect": { + "type": "openIdConnect", + "description": "OpenID Connect token issued by the platform identity provider. Some operations additionally require current MFA assurance, read from the token at request time.", + "openIdConnectUrl": "https://idp.example.invalid/.well-known/openid-configuration" + }, + "ApiKey": { + "type": "apiKey", + "description": "Platform API key: `Authorization: Bearer `. Scopes are `read_only` and `full_scan`. A scan launched with a key is recorded with `source = api`. Creating or revoking a key requires an OpenID Connect token with current MFA assurance.", + "in": "header", + "name": "Authorization" + } + } + } +} diff --git a/docs/reference/api-design-v4_0_1.md b/docs/reference/api-design-v4_0_1.md new file mode 100644 index 0000000..68c1b40 --- /dev/null +++ b/docs/reference/api-design-v4_0_1.md @@ -0,0 +1,374 @@ +# API design — v4.0.1 MVP + +**Status:** delivery candidate **Base path:** `/api/v1` + +**Scope:** API contracts, rules, and endpoint inventory for the NC3 Testing Platform v4.0 MVP. + +## 1. Contract principles + +- A ScanJob is always read through the canonical `/scans` resource, whether the caller is authenticated or a guest. +- `POST /scans` selects the request schema by media type: `application/json` launches a domain scan, `multipart/form-data` launches a file scan, any other media type receives `415`. +- Inside the JSON media type, the access state selects the variant: authenticated requests carry `asset_id`, unauthenticated requests carry `target`. +- Seven operations accept an anonymous caller: `POST /scans`, the three guest scan reads (§2.3), `GET /statements`, `GET /invitations/{token}`, `GET /feeds/{token}`. Every other operation requires an OpenID Connect token or a platform API key and answers `401` without one. `/healthz` and `/readyz` sit outside `/api/v1`. +- `ScanJob` and `ScanTask` state comes from the API. SSE events are advisory; when an event and a read disagree, the read is correct. +- Errors use the RFC 9457 Problem Details envelope. +- Some collection endpoints use cursor pagination and stable ordering. Lists order newest first, keyed on the UUIDv7 `id`; the audit log orders by (`chain_id`, `sequence_number`) (§15). +- Shapes fixed by the generated contract are versioned by the OpenAPI document itself. Three payloads live outside the document and carry their own `schema_version`: scan results, webhook payloads, and notification `data`. +- The identity provider owns identity, credentials, authentication methods, sessions, MFA enrollment, current assurance, and platform-administrator claims. The application owns its `app_user` projection, organization membership and role, assets, and verification state. + +## 2. Scan launch and lifecycle + +```http +POST /api/v1/scans +GET /api/v1/scans +GET /api/v1/scans/{scan_id} +GET /api/v1/scans/{scan_id}/results +GET /api/v1/scans/{scan_id}/events +POST /api/v1/scans/{scan_id}/cancel +POST /api/v1/scans/{scan_id}/claim +``` + +### 2.1 Domain launch (JSON) + +`POST /scans` with `Content-Type: application/json` accepts exactly one target field, selected by access state. + +- **Registered + user:** carries `asset_id`. The Asset must belong to the caller's organization. Carrying `target` answers `422`. +- + +**Guest:** carries `target`, a domain as free text — the only place in the API where free target text exists. The server canonicalizes it to lowercase IDNA (A-label) form without a trailing dot; text that does not parse as a domain answers `422`. Carrying `asset_id` answers `422`. Limited to non-intrusive tests. Anti-abuse gates apply. Persisted in `scan_job.target_domain` with no organization until claimed (§2.3). + +- Both variants carry `modules`, a list of one or more. Requesting the `file` module answers `422`. +- Both variants may carry `module_configuration`. Each module defines its own option shape; the web module's subdomain-discovery option is one example. +- Neither variant accepts file data. + +Rules: + +- One launch produces one ScanTask per executable test in the selected modules. Each task queues, succeeds or fails, and is graded independently of the others. +- Verification, current MFA assurance, and declaration gates are evaluated when the selected tests require them. Operations reserved for registered users stay gated per operation. +- When a launch requires declarations, the request carries responses to the required versioned Statements, and the server records immutable, context-bound `StatementResponse` rows. + +A launch answers `202 Accepted` with the ScanJob resource. Declarations sent with it are bound to the returned identifier. Gates run before the ScanJob is created, so the response carries the whole outcome. + +### 2.2 File launch (multipart) + +`POST /scans` with `Content-Type: multipart/form-data` launches a File-module scan. It requires one `file` part and may +include File-module configuration. It carries no target field and no `modules` field. + +Server rules: + +1. Quota: 100 uploads per org per day for registered users, 5 per IP per day for guests, by default. +2. Size: uploads above the configured maximum (50 MB by default) are rejected. +3. MIME type is detected from raw bytes and checked against the configured allow-list. Detection ignores the declared `Content-Type` and the filename extension. +4. FileUpload metadata, ScanJob, and initial File ScanTasks are created in one step. +5. Response is `202 Accepted` with the same ScanJob representation as other scans. It never exposes `storage_key`. +6. A rejected upload leaves no durable FileUpload row. +7. Accepted bytes are purged after analysis, or at the 24-hour deadline if analysis stalls. +8. ScanJob, results, and findings follow §11 retention. File ScanTasks use `classification = not_applicable`. + +### 2.3 Guest access, reading, and claim + +An unauthenticated launch returns the ScanJob state plus `claim_token`: a 256-bit random value, base64url, shown once. Only its hash is stored. + +**Reading.** These three operations accept the token as a `claim_token` query parameter, and declare a credential as an alternative to it: + +```http +GET /api/v1/scans/{scan_id} +GET /api/v1/scans/{scan_id}/results +GET /api/v1/scans/{scan_id}/events +``` + +**Claiming.** `POST /scans/{scan_id}/claim` carries `claim_token` in the request body and requires an authenticated caller. If the job is an unclaimed guest job, the hash matches, and the retention deadline has not passed, it becomes claimed by the user and organization, and the stored hash is discarded. + +- Every claim failure answers `404`. +- For a file scan, the claim also sets `file_upload.organization_id` to the claimed organization and `file_upload.uploaded_by_user_id` to the claiming user. + +### 2.4 Statuses, timeout, and cancellation + +- Job statuses: `queued`, `running`, `completed`, `partial`, `failed`, `canceled`. +- Task statuses: `queued`, `running`, `completed`, `failed`, `skipped`, `blocked`, `canceled`. +- A ScanResult belongs to one ScanTask, not directly to the ScanJob. +- Specific causes use stable machine-readable `status_reason` values. Labels, descriptions, localization, and operator guidance stay code-owned. +- A task timeout produces `failed` with a timeout `status_reason`. A job timeout terminates unfinished work; the job becomes `partial` when usable results exist, otherwise `failed`. +- `POST /scans/{scan_id}/cancel` records cancellation intent and preserves scan history. `DELETE` is never used to stop execution. + +## 3. Live progress + +`GET /scans/{scan_id}/events` is an SSE stream. Client pattern: + +1. Fetch the current job/task snapshot. +2. Subscribe to the stream. +3. Apply advisory events; task events carry the public `task_id`. +4. Refetch the snapshot after reconnection or on uncertainty. + +A client that misses events refetches the snapshot; the stream does not replay them. + +### 3.1 Event types + +| `event:` | Payload | Fires | +|-------------|-----------------------------------------------------|-------------------------------------| +| `task` | `task_id`, `status`, `status_reason`, `occurred_at` | Every task state transition | +| `job` | `status`, `status_reason`, `occurred_at` | Every job state transition | +| `heartbeat` | `occurred_at` | On an interval | +| `end` | `status`, `occurred_at` | Once, at terminal state. Last event | + +- `status_reason` is present on a terminal status. +- No event carries a completion percentage. The step-1 snapshot gives the total task count; each terminal `task` event advances the finished count. + +## 4. Scheduling + +```http +GET /api/v1/schedules +POST /api/v1/schedules +GET /api/v1/schedules/{schedule_id} +PATCH /api/v1/schedules/{schedule_id} +DELETE /api/v1/schedules/{schedule_id} +``` + +- Schedule creation requires an Asset that is currently eligible under verification rules. +- At execution time, re-verification runs before the ScanJob is created. +- When the gate fails: no ScanJob is created; the platform records an audit event; the platform creates the applicable user notifications; the schedule advances to its next-run state. +- A failed gate is never represented as a failed scan. + +## 5. Assets and domain verification + +```http +GET /api/v1/assets +POST /api/v1/assets +GET /api/v1/assets/{asset_id} +PATCH /api/v1/assets/{asset_id} +DELETE /api/v1/assets/{asset_id} +GET /api/v1/assets/{asset_id}/scans +GET /api/v1/assets/{asset_id}/verification +POST /api/v1/assets/{asset_id}/verification +POST /api/v1/assets/{asset_id}/verification/checks +POST /api/v1/assets/{asset_id}/verification/token +GET /api/v1/assets/{asset_id}/feeds +POST /api/v1/assets/{asset_id}/feeds +POST /api/v1/assets/{asset_id}/feeds/{feed_id}/revoke +``` + +- An Asset is an organization-owned monitored domain. Creator identity is attribution only. +- `PATCH` changes `regression_alerts_enabled`, the only mutable property. `value` and `asset_type` are immutable. +- `DELETE` answers `409` while scan history, discovered children, a verification, a schedule, or a feed reference the asset. + +### 5.1 Verification + +Verification is a separate resource nested under the Asset. Its representation carries the coverage already proven and the challenge currently running, and either of the two may be absent. + +- `POST .../verification` creates a challenge with the requested `exact` or `zone` scope. On an already-verified asset the challenge is created beside the standing proof. +- `POST .../checks` triggers a DNS check. +- `POST .../token` replaces the challenge token. + +The `challenge` object fully specifies the record to publish: + +| Field | Example | Meaning | +|----------------------|---------------------------|-----------------------------------------------------------------------------| +| `record_type` | `TXT` | Type of DNS record to create | +| `record_name` | `_nc3-verify.example.lu` | Where to create it. Server-computed from the domain and a configured prefix | +| `verification_token` | `verify-4f7a2c9e1b8d3056` | The complete record value, pasted verbatim | + +Rules: + +- Clients display the returned `record_name` rather than rebuilding it. +- A challenge expires seven days after issue by default: a verification reads as `expired` once `token_expires_at` has passed with no coverage proven. An asset that is already verified reads as `verified` past that deadline, whatever its challenge is doing. +- `POST .../token` answers `409` on a verified asset. Re-proving ownership or widening scope starts a new challenge with `POST .../verification`, which leaves `verified_scope` intact until the new challenge succeeds. +- `POST .../verification` replaces the asset's existing challenge, expired or active. Superseded attempts remain in the audit record. +- `POST .../checks` sets `challenge.last_recheck_at` whether or not the record was found. A not-found result answers `200` with the challenge still in place and a `failure_code`. +- Verification statuses are `pending`, `verified`, and `expired`. The status is computed from `verified_scope` and `challenge`, so no request has to reconcile the three of them. +- `POST .../verification` requires current MFA assurance. +- Current MFA assurance is read from the identity provider's session or token. An operation that requires it therefore declares only the OpenID Connect scheme: a platform API key carries no assurance. +- A verified domain is rechecked before an intrusive task is queued. No v4.0 test is intrusive, so nothing rechecks automatically in the MVP. + +### 5.2 Feeds + +- `POST .../feeds` creates a feed. The response is the only place the plaintext token and the full feed URL appear; only the hash is stored. +- `POST .../feeds/{feed_id}/revoke` stops serving a feed and keeps its row. +- Public delivery is `GET /feeds/{token}` (§7). + +## 6. Findings + +```http +GET /api/v1/findings +GET /api/v1/findings/{finding_id} +``` + +- `new`, `regression`, `persistent`, and `resolved` are immutable classifications derived from historical scan comparison. No operation mutates them. +- `GET /findings` takes four filters: + +| Filter | Restricts to | +|---------------|------------------------------------------| +| `severity` | one severity band | +| `status` | one historical-comparison classification | +| `asset_id` | findings raised against one asset | +| `scan_job_id` | findings from one scan | + +- `asset_id` reaches an asset through `scan_result` → `scan_task` → `asset`. + +## 7. Reports and feeds + +```http +POST /api/v1/reports +GET /api/v1/reports +GET /api/v1/feeds/{token} +``` + +### 7.1 Reports + +`POST /reports` accepts exactly one source: `source_scan_job_id` or `source_scan_task_id`. It renders synchronously and returns the document in the response body, with the media type matching the requested `format`: + +| `format` | Response media type | +|----------|---------------------------------------------------------------------------| +| `pdf` | `application/pdf` | +| `docx` | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` | +| `json` | `application/json` | + +- The response carries no report identifier, so a downloaded document cannot be matched to a `GET /reports` row. +- `generated_by_user_id` comes from the authenticated caller. +- To obtain a document again, submit another `POST /reports` while the source data is still retained. After `purge_at` passes, the provenance row remains and the operation answers `409`. +- Only the metadata is stored. +- Report content is assembled from scan results, whose shapes belong to the scan modules. Until those exist, only the format mapping above is settled. + +### 7.2 Feed delivery + +- `GET /feeds/{token}` serves a feed as `application/rss+xml` or `application/atom+xml` per the feed's configured + format. The token in the path is the entire authorization. +- A revoked feed answers `410`. +- Feed creation and revocation are per-asset operations (§5.2). + +## 8. User notifications + +```http +GET /api/v1/notifications +POST /api/v1/notifications/{notification_id}/read +POST /api/v1/notifications/read-all +DELETE /api/v1/notifications/{notification_id} +GET /api/v1/account +PATCH /api/v1/account +``` + +- Notifications are user-owned inbox items. Recipient selection is feature-specific application logic. +- In-app delivery has no opt-out. +- `DELETE` permanently removes the requesting user's row. +- `POST /read-all` answers `204`. +- `GET /account` returns the read-only `app_user` projection: `id`, `email`, `display_name`, `organization_id`, `organization_role`, `email_notifications_enabled`. +- `PATCH /account` changes `email_notifications_enabled` and nothing else. Profile data is owned by the identity + provider and reaches this projection through claim updates. + +## 9. Organization webhook + +```http +GET /api/v1/notifications/webhook +PUT /api/v1/notifications/webhook +DELETE /api/v1/notifications/webhook +``` + +- An organization has zero or one webhook configuration. +- `PUT` creates or replaces it. `DELETE` disables the integration by deleting the configuration. +- Payload `schema_version` is part of the signed webhook contract, not configuration state. +- Retry, backoff, and delivery processing are internal application/outbox concerns. + +## 10. Organization membership and invitations + +### 10.1 Invitations + +Admin operations: + +```http +GET /api/v1/org/invitations +POST /api/v1/org/invitations +DELETE /api/v1/org/invitations/{invitation_id} +``` + +Invitee operations: + +```http +GET /api/v1/invitations/{token} +POST /api/v1/invitations/{token}/acceptance +``` + +Rules: + +- The plaintext token appears only in the invitation link. The database stores only its unique hash. +- `DELETE` revokes the invitation. The lifecycle row is kept. +- Acceptance is authenticated and atomic. It requires an unexpired, unaccepted, non-revoked token, plus a verified user email matching the invited email. +- A user who already belongs to another organization cannot accept. +- Resending means revoking, then issuing a new invitation. +- `GET /invitations/{token}` is unauthenticated. It returns the organization name, the offered role, the invited address, and the expiry. A spent, revoked, or expired token answers `410`. + +### 10.2 Members + +```http +GET /api/v1/org/members +PATCH /api/v1/org/members/{user_id} +POST /api/v1/org/members/{user_id}/disable +POST /api/v1/org/members/{user_id}/enable +``` + +- All four require the `organization_admin` role. +- `PATCH` changes `organization_role` only, and answers `409` when the change would leave no enabled `organization_admin`. +- There is no `POST /org/members`. +- A registered user belongs to exactly one organization for the life of the account; removal is not modeled. `disable` ends access by setting `disabled_at`; a disabled user cannot authenticate against the application. Erasure is a separate workflow (§12). + +## 11. Retention and hard deletion + +```http +POST /api/v1/scans/{scan_id}/retention/extend +DELETE /api/v1/scans/{scan_id} +``` + +- Every terminal ScanJob exposes a read-only `purge_at`, the final hard-deletion timestamp. Default `finished_at + 12 months + 30 days`. The platform sends notice 30 days before it. +- An unclaimed guest job's `purge_at` is `created_at + 24 hours`, set at creation. A successful claim recomputes `purge_at` under the normal rule, and notice applies from that point. +- Purging at the deadline does not wait for the job to finish; unfinished work is terminated. +- The extension operation updates `purge_at` and records an audit event. It takes no request body: how far the deadline moves is platform configuration. Read the new deadline from `purge_at` in the response. +- `DELETE /scans/{scan_id}` is hard deletion, distinct from `POST .../cancel`. + +## 12. Account data access and product exports + +- User-facing table exports operate on data that the normal list APIs already return. +- GDPR access and portability are a separate compliance responsibility concerning currently retained personal data. +- Account erasure is a multi-initiator workflow: a user request, a platform operator, or identity-provider account deletion. v4.0 defines no public self-service erasure endpoint. The 30-day completion guarantee and the erasure steps apply regardless of initiator, and every initiation is recorded in the audit log. + +## 13. API keys + +```http +GET /api/v1/api-keys +POST /api/v1/api-keys +POST /api/v1/api-keys/{key_id}/revoke +``` + +- A key belongs to one user or to the organization when `owner_user_id` is null. Organization keys require the `organization_admin` role to create. +- `POST /api-keys` returns the plaintext secret once. Only a lookup prefix and a hash are stored. +- Creation accepts an optional `expires_at`. Absent means no expiry. +- Revocation is a `POST`. The row is kept with `revoked_at` and `revocation_reason`, and revoked keys stay listed. +- Creating or revoking a key consumes current MFA assurance, so both operations declare only the OpenID Connect scheme. +- Erasing an account also revokes and deletes that user's keys. +- `read_only` permits `GET` operations. `full_scan` is additionally required to launch a scan, which is recorded with `source = api`. + +## 14. Statements + +```http +GET /api/v1/statements +POST /api/v1/statement-responses +``` + +- `GET /statements` returns the statements currently in force — `effective_at` reached, not retired — each with its `id`, `statement_key`, `version`, `response_kind`, `required_context_type`, `content_hash`, `content_uri`, and `effective_at`. Unauthenticated. +- A client must send the exact version it answered. This operation is the only way to learn which version is current. +- `POST /statement-responses` records an account-level response, where `required_context_type` is null. It rejects a statement that requires a context; a per-launch declaration travels in the launch payload (§2.1). +- No v4.0 executable test is classified as intrusive, so no v4.0 launch requires a per-launch declaration. + +## 15. Audit log + +```http +GET /api/v1/admin/audit-events +``` + +- Requires the platform-administrator claim, which is independent of any organization role. An organization administrator has no access. +- Cursor pagination, ordered by (`chain_id`, `sequence_number`) — the pair that defines the hash chain. +- Filters: `chain_id`, `organization_id`, `event_type`, and a time range on `occurred_at`. +- The response returns the stored representation, including `detail` and the hash-chain fields. Encrypted payloads are returned as ciphertext; payload decryption is a separate operator procedure. + +## 16. Organization settings and white-label + +`organization.settings` and `white_label_config` are internal, deferred to ≥4.1. + diff --git a/docs/reference/data-model-v4_0_1.md b/docs/reference/data-model-v4_0_1.md new file mode 100644 index 0000000..eb117c2 --- /dev/null +++ b/docs/reference/data-model-v4_0_1.md @@ -0,0 +1,818 @@ +# Data model — v4.0.1 MVP + +**Status:** delivery candidate **Database:** PostgreSQL + +**Scope:** Based on NC3 Testing Platform v4.0 (MVP) requirements; entities, columns, and constraints. + +## 1. Scope, terminology, and system boundaries + +Conventions: + +- Entity names are singular. Database identifiers use `snake_case`. +- Primary keys are UUIDv7 values, so primary-key order is creation order; list pagination keysets on `id`. +- Timestamps use UTC `timestamptz`. +- Retention is evaluated per data class, processing purpose, lifecycle anchor event, applicable policy version, and disposition at the deadline. No single timestamp is the complete lifecycle model. +- System-boundary references and copied envelope identifiers without foreign keys are not ER links. + +### 1.1 Terminology + +| Term | Meaning in this model | +|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Organization | Tenant boundary. Registered users, assets, scan history, schedules, reports, API keys, and organization settings belong to one organization. | +| AppUser | The single local user entity. References one identity-provider identity and stores platform-owned user and organization fields. | +| UserKeyEnvelope | Mutable one-to-one record containing a per-user KEK encrypted by the deployment master key. Deleting it makes retained user evidence unrecoverable. | +| Asset | Organization-owned monitored target. v4.0 assets are domains. | +| OrganizationInvitation | Pending invitation to join one organization with one organization role. | +| ScanJob | One submitted scan request. | +| ScanTask | Persisted execution of one executable test against one domain or one uploaded file. Represents fan-out, independent task state, and partial results. | +| Statement | Versioned text requiring an explicit acceptance or attestation. | +| StatementResponse | Immutable response record for one Statement, optionally bound to a model context such as a ScanJob. Acceptance and attestation share the receipt shape but remain distinct response kinds. | +| FileUpload | Metadata for one uploaded file. The file bytes are temporary and are not retained with the scan result. | +| AssetFeed | Persistent per-asset RSS or Atom feed configuration with a revocable access token. | +| Notification | User-owned in-app inbox item. | +| OrganizationWebhook | Optional singular integration endpoint configured by one organization. | + +`scan_task.test_key` names an executable test. `finding.check_id` names the stable diagnostic rule. The specifications use `check` for both; this model separates them. + +### 1.2 System boundaries + +| Boundary | Input used by this model | Output or reference stored here | +|-----------------------------------|------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------| +| Identity provider | Subject identifier, projected user claims, current MFA assurance, platform-administrator claim | `app_user.identity_subject` and user foreign keys. MFA assurance and platform-administrator status are evaluated at request time. | +| Executable-test registry | Test key, version, module, classification, configuration schema, result schema | Immutable execution metadata copied to `scan_task`. | +| Temporary file storage | Uploaded file bytes referenced by a storage key; purge completion | `file_upload` metadata, storage reference, and purge timestamps. | +| Scan queue and workers | `scan_task.id` as the queue task identifier, plus task configuration | Task status, cancellation outcome, `scan_result`, and `finding` rows. Queue topology and transport stay queue-owned. | +| Deployment master-key file | Versioned 256-bit master key mounted read-only at `/run/secrets/app_encryption_master_key` | Used in memory to encrypt or decrypt `user_key_envelope.wrapped_kek`. Only `master_key_version` is stored in PostgreSQL. | +| Anti-abuse controls | Guest, user, organization, and target identifiers | Allow or deny outcome. Rate-limit and cooldown counters stay in the anti-abuse subsystem. | +| Report renderer and feed endpoint | Retained scan data, report metadata, feed configuration | `report` metadata and `asset_feed` configuration. Rendered files and feed responses are generated on demand. | + +## 2. Enumerations + +### 2.1 Organizations and users + +| Used by | PostgreSQL type | Values | +|---------------------------------------|---------------------|--------------------------------| +| `app_user`, `organization_invitation` | `organization_role` | `member`, `organization_admin` | + +### 2.2 Assets and verification + +| Used by | PostgreSQL type | Values | +|--------------------------------------------------------|----------------------|-----------------------| +| `asset` | `asset_type` | `domain` | +| `asset` | `asset_origin` | `added`, `discovered` | +| `domain_verification`, `domain_verification_challenge` | `verification_scope` | `exact`, `zone` | +| `domain_verification_challenge` | `dns_record_type` | `TXT` | + +`dns_record_type` values are uppercase. + +### 2.3 Statements and responses + +| Used by | PostgreSQL type | Values | +|-------------|---------------------------|-----------------------------| +| `statement` | `statement_response_kind` | `acceptance`, `attestation` | + +### 2.4 Scan execution and results + +| Used by | PostgreSQL type | Values | +|-------------------------------------|-----------------------|------------------------------------------------------------------------------| +| `scan_job` | `scan_source` | `guest`, `manual`, `schedule`, `api` | +| `scan_job` | `scan_job_status` | `queued`, `running`, `completed`, `partial`, `failed`, `canceled` | +| `scan_job`, `scan_task`, `schedule` | `scan_module` | `email`, `web`, `file`, `pqc`, `dnssec` | +| `scan_task` | `scan_classification` | `non_intrusive`, `intrusive`, `not_applicable` | +| `scan_task` | `scan_task_status` | `queued`, `running`, `completed`, `failed`, `skipped`, `blocked`, `canceled` | +| `scan_result` | `scan_grade` | `A+`, `A`, `B`, `C`, `D`, `F` | +| `finding` | `finding_severity` | `critical`, `high`, `medium`, `low`, `info` | +| `finding` | `finding_status` | `new`, `regression`, `persistent`, `resolved` | + +### 2.5 API access, reporting, and notifications + +| Used by | PostgreSQL type | Values | +|--------------|-------------------------|--------------------------| +| `api_key` | `api_key_scope` | `read_only`, `full_scan` | +| `report` | `report_tier` | `executive`, `technical` | +| `report` | `technical_report_view` | `full`, `summary` | +| `report` | `report_format` | `pdf`, `docx`, `json` | +| `report` | `report_language` | `en`, `fr`, `de` | +| `asset_feed` | `feed_format` | `rss`, `atom` | + +Namespaced text values, not database enums: `statement_key`, `required_context_type`, `test_key`, `check_id`, `notification.type`, `scan_job.status_reason`, `scan_task.status_reason`, `audit_event.event_type`. Status-reason labels, descriptions, localization, and operator guidance are code-owned. + +## 3. Organizations and users + +### 3.1 `organization` + +| Column | Type | Constraints | +|----------------------|-------------|------------------------| +| `id` | UUID | Primary key | +| `name` | text | Not null | +| `settings` | JSONB | Not null; default `{}` | +| `white_label_config` | JSONB | Not null; default `{}` | +| `created_at` | timestamptz | Not null | +| `updated_at` | timestamptz | Not null | + +### 3.2 `app_user` + +| Column | Type | Constraints | +|-------------------------------|---------------------|--------------------------------------------| +| `id` | UUID | Primary key | +| `organization_id` | UUID | Not null; foreign key to `organization.id` | +| `identity_subject` | text | Not null; unique | +| `email` | text | Not null | +| `display_name` | text | Nullable | +| `email_notifications_enabled` | boolean | Not null; default `false` | +| `organization_role` | `organization_role` | Not null | +| `disabled_at` | timestamptz | Nullable | +| `created_at` | timestamptz | Not null | +| `updated_at` | timestamptz | Not null | + +- A registered platform user belongs to exactly one organization. +- Platform-administrator status comes from the identity provider and is independent of the organization role. +- The identity provider stays the system of record for identity, credentials, authentication methods, sessions, and MFA enrollment. + +### 3.3 `user_key_envelope` + +Mutable one-to-one table storing one random per-user KEK, encrypted by the deployment master key. Key wrapping is application-owned. PostgreSQL stores only the wrapped KEK and its master-key version; plaintext keys exist only in application memory. + +| Column | Type | Constraints | +|----------------------|-------------|---------------------------------------------------------------------| +| `id` | UUID | Primary key; opaque envelope identifier | +| `organization_id` | UUID | Not null; foreign key to `organization.id` | +| `user_id` | UUID | Not null; unique; foreign key to `app_user.id`; `ON DELETE CASCADE` | +| `wrapped_kek` | bytea | Not null; user KEK encrypted by the deployment master key | +| `wrapping_nonce` | bytea | Not null | +| `wrapping_algorithm` | text | Not null | +| `master_key_version` | text | Not null | +| `created_at` | timestamptz | Not null | +| `updated_at` | timestamptz | Not null | + +Constraints: + +- `id` is random and never reused. It must not encode an `app_user.id`, identity-provider subject, email address, or any other user identifier. +- `organization_id` equals the linked AppUser organization. +- Master-key rotation re-encrypts `wrapped_kek` and updates `master_key_version`. Retained audit and statement payloads are not re-encrypted. +- Deleting this row makes all DEKs wrapped by its user KEK unrecoverable. + +Backup rules: the master-key backup is stored separately from PostgreSQL backups. Database backups containing deleted envelopes expire within 30 days. A restored backup must replay completed erasures before the service is exposed. + +### 3.4 `organization_invitation` + +| Column | Type | Constraints | +|-----------------------|---------------------|--------------------------------------------------------------| +| `id` | UUID | Primary key | +| `organization_id` | UUID | Not null; foreign key to `organization.id` | +| `email` | text | Not null | +| `organization_role` | `organization_role` | Not null | +| `token_hash` | text | Not null; unique; hash of the plaintext invitation token | +| `invited_by_user_id` | UUID | Nullable; foreign key to `app_user.id`; `ON DELETE SET NULL` | +| `expires_at` | timestamptz | Not null | +| `accepted_by_user_id` | UUID | Nullable; foreign key to `app_user.id`; `ON DELETE SET NULL` | +| `accepted_at` | timestamptz | Nullable | +| `revoked_at` | timestamptz | Nullable | +| `created_at` | timestamptz | Not null | + +Rules: + +- Only one unexpired, unaccepted, non-revoked invitation may exist for the same organization and normalized email address. +- The plaintext token is sent in the invitation link and is never stored. +- Acceptance is atomic. It requires an authenticated user whose verified email matches the invitation and who does not already belong to another organization. It sets `accepted_at` and `accepted_by_user_id` together; the latter may become null through user erasure. + +### 3.5 User erasure treatment + +Plain `app_user.id` values appear only where the reference can be removed during erasure. Retained evidence stores the opaque `user_key_envelope.id` value, without a foreign key to the envelope or the user. + +| Reference category | Examples | Erasure behavior | +|----------------------------------------|-----------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------| +| User-owned operational data | Notifications and user-owned API keys | Delete with the user. | +| Attribution on organization-owned data | Asset creator, scan trigger, verification requester, schedule creator, report generator | Keep the organization-owned row and set the user foreign key to null. | +| Immutable retained evidence | Statement responses and audit events | Store no `app_user.id`. Encrypt identity and other user PII with a per-event DEK wrapped by the user-specific KEK. Delete `user_key_envelope` during erasure. | + +Account erasure completes within 30 days. The workflow deletes `user_key_envelope`, deletes the `app_user` row and user-owned data, and nulls attribution references before the request is closed. + +```mermaid +erDiagram + ORGANIZATION ||--o{ APP_USER: contains + ORGANIZATION ||--o{ USER_KEY_ENVELOPE: scopes + APP_USER ||--|| USER_KEY_ENVELOPE: has + ORGANIZATION ||--o{ ORGANIZATION_INVITATION: issues + APP_USER o|--o{ ORGANIZATION_INVITATION: invites + APP_USER o|--o{ ORGANIZATION_INVITATION: accepts +``` + +## 4. Assets and domain verification + +### 4.1 `asset` + +| Column | Type | Constraints | +|-----------------------------|----------------|---------------------------------------------------------------------------------------------------| +| `id` | UUID | Primary key | +| `organization_id` | UUID | Not null; foreign key to `organization.id` | +| `asset_type` | `asset_type` | Not null | +| `value` | text | Not null; lowercase IDNA A-label domain without a trailing dot, canonicalized at the API boundary | +| `origin` | `asset_origin` | Not null | +| `parent_asset_id` | UUID | Nullable; foreign key to `asset.id` | +| `created_by_user_id` | UUID | Nullable; foreign key to `app_user.id`; `ON DELETE SET NULL` | +| `regression_alerts_enabled` | boolean | Not null; default `false` | +| `created_at` | timestamptz | Not null | +| `updated_at` | timestamptz | Not null | + +- Unique constraint: (`organization_id`, `asset_type`, `value`). +- An Asset is organization-owned. `created_by_user_id` records attribution and does not assign ownership. +- Deletion is restricted: an asset referenced by scan history or by discovered children answers `409`. Referencing foreign keys restrict, never cascade or set null. + +Ownership is held in two tables. `domain_verification` records a proof that exists, and `domain_verification_challenge` records a challenge in progress. They are independent, so a domain keeps the coverage it has already proven while it re-proves ownership or asks for wider coverage. + +### 4.2 `domain_verification` + +| Column | Type | Constraints | +|-------------------|----------------------|---------------------------------------------| +| `id` | UUID | Primary key | +| `organization_id` | UUID | Not null; foreign key to `organization.id` | +| `asset_id` | UUID | Not null; unique; foreign key to `asset.id` | +| `verified_scope` | `verification_scope` | Not null | +| `verified_at` | timestamptz | Not null | + +Constraints: + +- The referenced Asset must have `asset_type = domain`. +- A row exists exactly while the domain is proven, so proof is presence rather than a stored status. +- Zone coverage is evaluated by DNS-label ancestry, not by string suffix matching. +- A successful check writes this row and deletes the challenge that produced it in one transaction, so `verified_scope` changes only at the moment a wider coverage is actually proven. +- The API status is computed from the two tables: `verified` while this row exists, `pending` while a challenge is unexpired, and `expired` otherwise. No stored enum carries it. +- This table stores the current proof only. Verification attempts and status changes are recorded in `audit_event`. + +### 4.3 `domain_verification_challenge` + +| Column | Type | Constraints | +|------------------------|----------------------|--------------------------------------------------------------| +| `id` | UUID | Primary key | +| `organization_id` | UUID | Not null; foreign key to `organization.id` | +| `asset_id` | UUID | Not null; unique; foreign key to `asset.id` | +| `requested_scope` | `verification_scope` | Not null | +| `record_type` | `dns_record_type` | Not null | +| `record_name` | text | Not null; DNS name at which the token is published | +| `verification_token` | text | Not null; the complete record value | +| `token_expires_at` | timestamptz | Not null; seven days after issue by default | +| `requested_by_user_id` | UUID | Nullable; foreign key to `app_user.id`; `ON DELETE SET NULL` | +| `requested_at` | timestamptz | Not null | +| `last_recheck_at` | timestamptz | Nullable | +| `failure_code` | text | Nullable | + +Constraints: + +- An asset has at most one challenge in progress, and a challenge may exist whether or not the asset is already proven. +- Creating a challenge replaces the asset's existing challenge row in the same transaction, expired or active, satisfying the unique `asset_id`. Superseded attempts remain in `audit_event`. +- `record_name` is computed as `_-verify.`, where the vendor prefix is deployment configuration. +- `verification_token` is the whole record value. A client publishes it verbatim. +- A challenge whose `token_expires_at` has passed answers no further checks. Reaching that deadline never touches an existing `domain_verification` row. +- Replacing the token is rejected while the asset has a `domain_verification` row. Re-proving ownership or widening scope starts a new challenge instead, and the existing proof holds until that challenge succeeds. +- `last_recheck_at` records the last time the record was looked for, whichever trigger caused it. +- `failure_code` records the outcome of the most recent check and is cleared when a check succeeds. +- The row is deleted when its check succeeds, so a spent token is never kept beside the proof it produced. + +```mermaid +erDiagram + ORGANIZATION ||--o{ ASSET: owns + ASSET o|--o{ ASSET: discovers + ASSET ||--o| DOMAIN_VERIFICATION: has + ASSET ||--o| DOMAIN_VERIFICATION_CHALLENGE: has + APP_USER o|--o{ ASSET: creates + APP_USER o|--o{ DOMAIN_VERIFICATION_CHALLENGE: requests +``` + +## 5. Statements and responses + +One table pair stores all v4.0 declarations: account-level acceptance of Terms, AUP, and privacy text; per-launch attestation of ownership or permission; and per-launch acceptance of intrusive-scan risks and responsibility. + +`statement` identifies and versions the exact text and the required response type. `statement_response` records an immutable receipt of the response and its optional model context. `response_kind` distinguishes acceptance from factual attestation without creating separate ledgers. + +### 5.1 `statement` + +| Column | Type | Constraints | +|-------------------------|---------------------------|-----------------------------------------------------------------------------------------| +| `id` | UUID | Primary key | +| `statement_key` | text | Not null | +| `version` | text | Not null | +| `response_kind` | `statement_response_kind` | Not null | +| `required_context_type` | text | Nullable; null for account-level responses; `scan_job` for per-launch responses in v4.0 | +| `content_hash` | text | Not null | +| `content_uri` | text | Nullable | +| `effective_at` | timestamptz | Not null | +| `retired_at` | timestamptz | Nullable | + +Unique constraint: (`statement_key`, `version`). + +Expected `statement_key` values: + +| `statement_key` | `response_kind` | `required_context_type` | +|---------------------------------|-----------------|-------------------------| +| `terms_and_conditions` | `acceptance` | null | +| `acceptable_use_policy` | `acceptance` | null | +| `privacy_notice` | `acceptance` | null | +| `scan_target_permission` | `attestation` | `scan_job` | +| `intrusive_scan_risk_liability` | `acceptance` | `scan_job` | + +### 5.2 `statement_response` + +| Column | Type | Constraints | +|-------------------------------|-------------|-------------------------------------------------------------------------------------| +| `id` | UUID | Primary key | +| `organization_id` | UUID | Nullable; foreign key to `organization.id` | +| `statement_id` | UUID | Not null; foreign key to `statement.id` | +| `envelope_id` | UUID | Not null; copied from `user_key_envelope.id` at write time; no foreign key retained | +| `context_type` | text | Nullable; namespaced value | +| `context_id` | UUID | Nullable | +| `responded_at` | timestamptz | Not null | +| `response_evidence_encrypted` | bytea | Not null; encrypted actor identity, IP address, user agent, and response evidence | +| `wrapped_dek` | bytea | Not null; per-response DEK wrapped by the user-specific KEK | +| `encryption_metadata` | JSONB | Not null; payload-encryption and DEK-wrapping algorithm and nonce metadata | + +Constraints: + +- `envelope_id` equals the responding user's `user_key_envelope.id` at write time. No foreign key is retained. +- `envelope_id` is opaque and never reused. It must not encode an `app_user.id`, identity-provider subject, email address, or any other user identifier. +- `context_type` and `context_id` are either both null or both non-null. +- When `statement.required_context_type` is null: both context columns are null, and the response is unique on (`statement_id`, `envelope_id`). +- When `statement.required_context_type` is non-null: `context_type` equals that value, and the response is unique on (`statement_id`, `context_type`, `context_id`). +- When `context_type = scan_job`: `context_id` identifies a ScanJob, `organization_id` equals the ScanJob organization, and the envelope belongs to the user who submitted the launch at response time. +- StatementResponse rows are immutable. A correction requires a new Statement version and a new response. Application roles have no `UPDATE` or `DELETE` permission on this table. +- Each StatementResponse is recorded in `audit_event`. +- User erasure deletes the applicable `user_key_envelope` and the `app_user` row. The StatementResponse remains, but its actor evidence cannot be decrypted, and no user foreign key survives. +- `statement.response_kind` identifies which action the user performed. `statement_key`, `version`, and `content_hash` identify the exact text. + +```mermaid +erDiagram + STATEMENT ||--o{ STATEMENT_RESPONSE: receives + SCAN_JOB o|--o{ STATEMENT_RESPONSE: contextualizes +``` + +## 6. File uploads + +### 6.1 `file_upload` + +| Column | Type | Constraints | +|-----------------------|-------------|------------------------------------------------------------------| +| `id` | UUID | Primary key | +| `organization_id` | UUID | Nullable; foreign key to `organization.id` | +| `uploaded_by_user_id` | UUID | Nullable; foreign key to `app_user.id`; `ON DELETE SET NULL` | +| `original_filename` | text | Not null | +| `declared_mime_type` | text | Nullable | +| `detected_mime_type` | text | Not null | +| `size_bytes` | bigint | Not null; maximum 50 MB under the default platform configuration | +| `sha256` | text | Not null | +| `storage_key` | text | Nullable; required while file bytes exist | +| `uploaded_at` | timestamptz | Not null | +| `purge_due_at` | timestamptz | Not null; no later than 24 hours after upload | +| `purged_at` | timestamptz | Nullable | + +Constraints: + +- At creation, both ownership fields are null for a guest upload. When the associated guest ScanJob is claimed, both are set to the claimed organization and the claiming user. +- At creation, both ownership fields are set for a registered-user upload. `uploaded_by_user_id` may later become null through user erasure. +- File bytes are purged after analysis, or when `purge_due_at` is reached. +- `storage_key` is null after purge. It must never resolve to a browser-accessible path. +- Scan results follow the retention defined by `scan_job.purge_at` in §7.1 after the file bytes are purged. +- An accepted file-scan launch creates the `file_upload`, `scan_job`, and initial File `scan_task` rows as one application operation, after raw-byte validation. A rejected upload leaves no durable FileUpload row. +- One FileUpload supplies at most one ScanJob. That ScanJob may fan out to multiple File ScanTasks: hash triage, deep analysis, metadata extraction, mismatch detection. + +```mermaid +erDiagram + ORGANIZATION o|--o{ FILE_UPLOAD: owns + APP_USER o|--o{ FILE_UPLOAD: uploads + FILE_UPLOAD o|--o| SCAN_JOB: supplies + FILE_UPLOAD o|--o{ SCAN_TASK: supplies +``` + +## 7. Scan execution + +### 7.1 `scan_job` + +| Column | Type | Constraints | +|------------------------|-------------------|-----------------------------------------------------------------------------------------------------| +| `id` | UUID | Primary key | +| `organization_id` | UUID | Nullable; foreign key to `organization.id` | +| `triggered_by_user_id` | UUID | Nullable; foreign key to `app_user.id`; `ON DELETE SET NULL` | +| `source` | `scan_source` | Not null | +| `schedule_id` | UUID | Nullable; foreign key to `schedule.id` | +| `api_key_id` | UUID | Nullable; foreign key to `api_key.id` | +| `asset_id` | UUID | Nullable; foreign key to `asset.id` | +| `target_domain` | text | Nullable; lowercase IDNA A-label domain not stored as an Asset | +| `file_upload_id` | UUID | Nullable; unique when present; foreign key to `file_upload.id` | +| `modules` | `scan_module[]` | Not null; what the launch asked for. Compare against the tasks to see what ran | +| `module_configuration` | JSONB | Not null; default `{}` | +| `status` | `scan_job_status` | Not null | +| `status_reason` | text | Nullable; stable namespaced reason code for job-wide exceptional or terminal outcomes | +| `claim_token_hash` | text | Nullable; hash of the one-time token returned by an unauthenticated launch | +| `claimed_by_user_id` | UUID | Nullable; foreign key to `app_user.id`; `ON DELETE SET NULL` | +| `claimed_at` | timestamptz | Nullable | +| `purge_at` | timestamptz | Nullable until terminal completion, except on an unclaimed guest job; final hard-deletion timestamp | +| `created_at` | timestamptz | Not null | +| `started_at` | timestamptz | Nullable | +| `finished_at` | timestamptz | Nullable | + +Retention: + +- `purge_at` is the final deletion boundary, not the start of a grace period. On terminal completion it is set to + `finished_at + 12 months + 30 days` by default. +- The application notifies responsible organization users 30 days before it, hard-deletes the scan data at that timestamp, and records an audit event for each extension. +- An unclaimed guest job's `purge_at` is `created_at + 24 hours`, set at creation. A successful claim recomputes `purge_at` under the rule above, and the 30-day notice applies from that point. +- Purging at the deadline does not wait for the job to finish; unfinished work is terminated first. +- How far one extension moves the deadline is deployment configuration, not schema. No retention-policy entity or database partition policy is part of v4.0. + +Constraints: + +- Exactly one of `asset_id`, `target_domain`, and `file_upload_id` is set. +- Authenticated domain launches use `asset_id`. Unauthenticated domain launches use the guest JSON variant and populate `target_domain`. File launches use the multipart transport and populate `file_upload_id`. No `asset_id | target | file` request union exists. +- `file_upload_id` is present only for File-module jobs. +- `target_domain` is populated only by unauthenticated guest domain launches. Guest jobs are limited to non-intrusive tests. A guest target is never an Asset row. +- `source = schedule` requires `schedule_id`. +- `source = api` requires `api_key_id`. +- At creation, `source = manual` requires `triggered_by_user_id`. The reference may later become null through user erasure. +- Guest jobs have no organization and no triggering user until claimed after registration. +- `claim_token_hash` is set on an unauthenticated launch and holds the hash of a 256-bit random token returned once to + the caller. The plaintext is never stored. +- Claiming is one atomic compare-and-set: the job is an unclaimed guest job, the supplied token hashes to `claim_token_hash`, and `purge_at` has not passed. Success sets `claimed_at`, `claimed_by_user_id`, the ownership fields, and nulls `claim_token_hash`. The same token reads the job before it is claimed, and reading leaves it usable. +- A claimed job carries `claimed_at` and an `organization_id`, the organization of the claiming user. `claimed_by_user_id` records who claimed it and may later become null through user erasure. For a file ScanJob, the claim also sets `file_upload.organization_id` and `file_upload.uploaded_by_user_id`. +- A job containing an intrusive ScanTask requires current `statement_response` rows for both `scan_target_permission` and `intrusive_scan_risk_liability`, each bound to that ScanJob. The responding user is the user who submitted the intrusive launch and belongs to the ScanJob organization. + +### 7.2 `scan_task` + +| Column | Type | Constraints | +|-----------------------------|-----------------------|--------------------------------------------------------------------------------------------| +| `id` | UUID | Primary key | +| `organization_id` | UUID | Nullable; foreign key to `organization.id` | +| `scan_job_id` | UUID | Not null; foreign key to `scan_job.id` | +| `parent_task_id` | UUID | Nullable; foreign key to `scan_task.id` | +| `module` | `scan_module` | Not null | +| `test_key` | text | Not null | +| `test_version` | text | Not null | +| `classification` | `scan_classification` | Not null | +| `target_asset_id` | UUID | Nullable; foreign key to `asset.id` | +| `target_domain` | text | Nullable | +| `file_upload_id` | UUID | Nullable; foreign key to `file_upload.id` | +| `configuration` | JSONB | Not null; default `{}` | +| `status` | `scan_task_status` | Not null | +| `status_reason` | text | Nullable; stable namespaced reason code for failed, skipped, blocked, or canceled outcomes | +| `cancellation_requested_at` | timestamptz | Nullable | +| `created_at` | timestamptz | Not null | +| `started_at` | timestamptz | Nullable | +| `finished_at` | timestamptz | Nullable | + +Constraints: + +- Exactly one of `target_asset_id`, `target_domain`, and `file_upload_id` is set. +- `test_key`, `test_version`, and `classification` are copied from the code-owned executable-test definition when the task is created. +- `classification = not_applicable` is used only by the File module. +- `status_reason` is required when `status = blocked`. +- `parent_task_id` records discovery and fan-out lineage for all-in-one scans. +- `scan_task.id` is supplied as the queue task identifier. No second queue-job identifier is stored. +- Setting `cancellation_requested_at` records durable cancellation intent. The queue task is revoked using `scan_task.id`. Workers check cancellation before starting and at safe interruption points. +- After cancellation is accepted: `status = canceled`, `finished_at` is set, and a later successful result is rejected. + +### 7.3 v4.0 executable-test catalog + +Owned by application code. Metadata is copied into `scan_task` at task creation. + +| `test_key` | Module | Classification | Produces a letter grade | +|-----------------------------|--------|------------------|-------------------------| +| `email.mailvalidator` | Email | `non_intrusive` | Yes | +| `web.headers` | Web | `non_intrusive` | Yes | +| `web.tls` | Web | `non_intrusive` | Yes | +| `web.subdomain_enumeration` | Web | `non_intrusive` | No | +| `file.hashlookup` | File | `not_applicable` | No | +| `file.pandora` | File | `not_applicable` | No | +| `file.metadata` | File | `not_applicable` | No | +| `file.mime_check` | File | `not_applicable` | No | +| `pqc.quantumvalidator` | PQC | `non_intrusive` | No | +| `dnssec.chainvalidator` | DNSSEC | `non_intrusive` | No | + +No v4.0 executable test has `classification = intrusive`. + +```mermaid +erDiagram + SCAN_JOB ||--o{ SCAN_TASK: contains + SCAN_TASK o|--o{ SCAN_TASK: creates + ASSET o|--o{ SCAN_JOB: targets + ASSET o|--o{ SCAN_TASK: targets + FILE_UPLOAD o|--o| SCAN_JOB: supplies + FILE_UPLOAD o|--o{ SCAN_TASK: supplies +``` + +## 8. Results and findings + +### 8.1 `scan_result` + +| Column | Type | Constraints | +|-------------------|--------------|-------------------------------------------------| +| `id` | UUID | Primary key | +| `organization_id` | UUID | Nullable; foreign key to `organization.id` | +| `scan_task_id` | UUID | Not null; unique; foreign key to `scan_task.id` | +| `schema_version` | text | Not null | +| `raw_output` | JSONB | Not null | +| `summary` | JSONB | Not null; default `{}` | +| `grade` | `scan_grade` | Nullable | +| `severity_counts` | JSONB | Nullable | +| `completed_at` | timestamptz | Not null | + +Constraints: + +- `grade` is used only for Email, Web headers, and Web TLS tasks. +- Non-graded tasks use severity counts or per-step verdicts in `summary`. +- No cross-module composite score is stored. + +### 8.2 `finding` + +| Column | Type | Constraints | +|-----------------------|--------------------|---------------------------------------------| +| `id` | UUID | Primary key | +| `organization_id` | UUID | Nullable; foreign key to `organization.id` | +| `scan_result_id` | UUID | Not null; foreign key to `scan_result.id` | +| `check_id` | text | Not null; stable diagnostic-rule identifier | +| `severity` | `finding_severity` | Not null | +| `status` | `finding_status` | Not null | +| `title` | text | Not null | +| `description` | text | Not null | +| `affected_resource` | text | Nullable | +| `remediation` | text | Nullable | +| `evidence` | JSONB | Nullable | +| `external_references` | JSONB | Not null; default `[]` | + +- Index: (`scan_result_id`, `check_id`). +- `finding.status` persists the derived historical-comparison classification with the result projection. +- `check_id` is stable across result-schema versions. +- When one diagnostic rule produces several findings, regression matching also uses the normalized `affected_resource`. +- A resolved prior finding is represented by a Finding row on the newer result that establishes the resolution. + +```mermaid +erDiagram + SCAN_TASK ||--o| SCAN_RESULT: produces + SCAN_RESULT ||--o{ FINDING: contains +``` + +## 9. Scheduling and API access + +### 9.1 `schedule` + +| Column | Type | Constraints | +|------------------------|-----------------|--------------------------------------------------------------| +| `id` | UUID | Primary key | +| `organization_id` | UUID | Not null; foreign key to `organization.id` | +| `asset_id` | UUID | Not null; foreign key to `asset.id` | +| `created_by_user_id` | UUID | Nullable; foreign key to `app_user.id`; `ON DELETE SET NULL` | +| `modules` | `scan_module[]` | Not null | +| `module_configuration` | JSONB | Not null; default `{}` | +| `recurrence_rule` | text | Not null; RFC 5545 RRULE | +| `timezone` | text | Not null; IANA timezone | +| `enabled` | boolean | Not null; default `true` | +| `next_run_at` | timestamptz | Nullable | +| `created_at` | timestamptz | Not null | +| `updated_at` | timestamptz | Not null | + +- A Schedule creates ScanJob rows. It does not store scan results. +- `timezone` is stored separately from `recurrence_rule`. + +### 9.2 `api_key` + +| Column | Type | Constraints | +|----------------------|-----------------|-------------------------------------------------------------------------------------------| +| `id` | UUID | Primary key | +| `organization_id` | UUID | Not null; foreign key to `organization.id` | +| `owner_user_id` | UUID | Nullable; foreign key to `app_user.id`; `ON DELETE CASCADE`; null for an organization key | +| `created_by_user_id` | UUID | Nullable; foreign key to `app_user.id`; `ON DELETE SET NULL` | +| `name` | text | Not null | +| `scope` | `api_key_scope` | Not null | +| `key_prefix` | text | Not null; unique | +| `secret_hash` | text | Not null | +| `expires_at` | timestamptz | Nullable | +| `revoked_at` | timestamptz | Nullable | +| `revocation_reason` | text | Nullable | +| `last_used_at` | timestamptz | Nullable | +| `created_at` | timestamptz | Not null | + +Rules: + +- The plaintext key is never stored. +- Key-management actions consume current MFA assurance from the identity provider. +- Account erasure revokes and deletes user-owned keys. The revocation stays represented by the audit event. + +```mermaid +erDiagram + ORGANIZATION ||--o{ SCHEDULE: owns + ASSET ||--o{ SCHEDULE: schedules + SCHEDULE o|--o{ SCAN_JOB: creates + ORGANIZATION ||--o{ API_KEY: owns + APP_USER o|--o{ API_KEY: owns + API_KEY o|--o{ SCAN_JOB: triggers +``` + +## 10. Reports and feeds + +### 10.1 `report` + +| Column | Type | Constraints | +|------------------------|-------------------------|--------------------------------------------------------------| +| `id` | UUID | Primary key | +| `organization_id` | UUID | Not null; foreign key to `organization.id` | +| `tier` | `report_tier` | Not null | +| `technical_view` | `technical_report_view` | Nullable; used only when `tier = technical` | +| `format` | `report_format` | Not null | +| `language` | `report_language` | Not null | +| `source_scan_job_id` | UUID | Nullable; provenance identifier; deliberately no foreign key | +| `source_scan_task_id` | UUID | Nullable; provenance identifier; deliberately no foreign key | +| `generated_by_user_id` | UUID | Nullable; foreign key to `app_user.id`; `ON DELETE SET NULL` | +| `generated_at` | timestamptz | Not null | + +Rules: + +- Exactly one of `source_scan_job_id` and `source_scan_task_id` is set. +- The application validates the source at generation time and reads results through ordinary joins in the generation query. +- The selected identifier remains as provenance metadata after the scan data is purged. From that point, no further report can be generated from that source. +- The rendered artifact is generated on demand and is not stored. + +### 10.2 `asset_feed` + +| Column | Type | Constraints | +|----------------------|---------------|--------------------------------------------------------------| +| `id` | UUID | Primary key | +| `organization_id` | UUID | Not null; foreign key to `organization.id` | +| `asset_id` | UUID | Not null; foreign key to `asset.id` | +| `format` | `feed_format` | Not null | +| `token_hash` | text | Not null | +| `created_by_user_id` | UUID | Nullable; foreign key to `app_user.id`; `ON DELETE SET NULL` | +| `revoked_at` | timestamptz | Nullable | +| `last_used_at` | timestamptz | Nullable | +| `created_at` | timestamptz | Not null | + +The plaintext feed token is not stored. The feed is read-only and per Asset. + +```mermaid +erDiagram + ORGANIZATION ||--o{ REPORT: owns + ASSET ||--o{ ASSET_FEED: exposes +``` + +## 11. Notifications and webhooks + +### 11.1 `notification` + +| Column | Type | Constraints | +|------------------|-------------|-------------------------------------------------------------| +| `id` | UUID | Primary key | +| `user_id` | UUID | Not null; foreign key to `app_user.id`; `ON DELETE CASCADE` | +| `type` | text | Not null; stable namespaced notification type | +| `schema_version` | text | Not null; version of this type's `data` shape | +| `data` | JSONB | Not null; default `{}` | +| `read_at` | timestamptz | Nullable | +| `created_at` | timestamptz | Not null | + +Rules: + +- A Notification is owned by one user. +- Recipient selection is feature-specific application logic. +- In-app delivery is mandatory. Clearing or dismissing a v4.0 notification hard-deletes that user's row. +- Email delivery is attempted only when `app_user.email_notifications_enabled` is true. +- Canonical v4.0 types include verification completion, regressions, scan completion and failure, retention warnings, and token expiry. The type vocabulary is code-owned. + +### 11.2 `organization_webhook` + +| Column | Type | Constraints | +|----------------------------|-------------|--------------------------------------------------------------| +| `id` | UUID | Primary key | +| `organization_id` | UUID | Not null; unique; foreign key to `organization.id` | +| `endpoint_url_encrypted` | bytea | Not null | +| `signing_secret_encrypted` | bytea | Not null | +| `created_by_user_id` | UUID | Nullable; foreign key to `app_user.id`; `ON DELETE SET NULL` | +| `created_at` | timestamptz | Not null | +| `updated_at` | timestamptz | Not null | + +Rules: + +- An organization has zero or one webhook configuration. Deleting the row disables the integration. +- Payload `schema_version` belongs to the signed webhook contract, not to this table. +- Delivery retries and backoff are application/outbox concerns. No `webhook_delivery` entity is required. + +```mermaid +erDiagram + APP_USER ||--o{ NOTIFICATION: receives + ORGANIZATION ||--o| ORGANIZATION_WEBHOOK: configures +``` + +## 12. Audit log + +User identity and other user PII are never stored as clear foreign keys in immutable audit rows: + +1. A per-event data-encryption key (DEK) encrypts the sensitive payload. +2. The DEK is wrapped by the user-specific KEK, which is stored encrypted in `user_key_envelope`. +3. The application unwraps that KEK using the deployment master key mounted at `/run/secrets/app_encryption_master_key`. + +For user-related events, `envelope_id` is copied from `user_key_envelope.id` at write time. It is not a foreign key and contains no user identifier. Deleting `user_key_envelope` removes both the usable user KEK and its link to the AppUser, without updating or deleting the audit event. + +### 12.1 `audit_event` + +| Column | Type | Constraints | +|-----------------------|-------------|----------------------------------------------------------------------------------------| +| `id` | UUID | Primary key | +| `organization_id` | UUID | Nullable; foreign key to `organization.id` | +| `chain_id` | text | Not null; organization or platform chain identifier | +| `sequence_number` | bigint | Not null | +| `event_type` | text | Not null; namespaced value | +| `subject_type` | text | Nullable; must not identify an AppUser | +| `subject_id` | UUID | Nullable; may reference a non-user model entity only | +| `detail` | JSONB | Nullable; structured operational detail containing no PII | +| `payload_encrypted` | bytea | Nullable; encrypted identity, IP address, user agent, and other sensitive event detail | +| `wrapped_dek` | bytea | Nullable; per-event DEK wrapped by the user-specific KEK | +| `envelope_id` | UUID | Nullable; copied from `user_key_envelope.id` at write time; no foreign key retained | +| `encryption_metadata` | JSONB | Nullable; payload-encryption and DEK-wrapping algorithm and nonce metadata | +| `occurred_at` | timestamptz | Not null | +| `previous_hash` | text | Nullable | +| `entry_hash` | text | Not null | +| `retention_until` | timestamptz | Not null; default 24 months after `occurred_at` | + +Unique constraint: (`chain_id`, `sequence_number`). + +Constraints: + +- Rows are append-only. Application roles have no `UPDATE` or `DELETE` permission. +- Audit events are retained for 24 months. +- Only platform administrators may read the audit log. +- User identity is stored only inside `payload_encrypted`. +- `chain_id` identifies an organization or platform chain, never a user chain. +- `detail` may contain non-PII operational values: status, counts, model identifiers. User identity, email, IP address, user agent, domains, and other PII belong in `payload_encrypted`, or are represented through a non-user `subject_id`. +- An event may contain `detail`, an encrypted payload, or both. +- `payload_encrypted`, `wrapped_dek`, `envelope_id`, and `encryption_metadata` are either all null or all non-null. +- For a user-related event, `envelope_id` equals the user's `user_key_envelope.id` at write time, but no foreign key constrains it. +- `envelope_id` is opaque and never reused. It must not encode an `app_user.id`, identity-provider subject, email address, or any other user identifier. +- One encrypted user-specific payload contains PII for at most one user. An operation involving PII for two users emits separately encrypted audit events. +- Deleting the applicable `user_key_envelope` makes the encrypted event detail inaccessible without deleting the audit row. +- `entry_hash` covers `previous_hash` and the canonical stored representation of the event, including `detail`, `payload_encrypted`, `wrapped_dek`, `envelope_id`, and `encryption_metadata`. +- `chain_id` and `sequence_number` define deterministic ordering within each organization or platform chain. + +The same user KEK wraps the per-response DEKs used by `statement_response`. Deleting `user_key_envelope` crypto-shreds retained response evidence and retained audit identity data in one erasure operation. + +```mermaid +erDiagram + ORGANIZATION o|--o{ AUDIT_EVENT: scopes +``` + +## 13. Cross-entity constraints + +1. Every organization-owned row carries `organization_id`. Guest scan rows may have a null `organization_id`. Foreign keys between organization-owned rows must reference rows in the same organization. +2. Row-level security is enforced on all organization-owned tables. `scan_task`, `scan_result`, and `finding` copy `organization_id` from `scan_job`. The value is rechecked when asynchronous results are written. +3. `asset.asset_type` is restricted to `domain` in v4.0. +4. A DomainVerification covers an intrusive target only when a `domain_verification` row exists for the asset and its `verified_scope` covers that target. +5. Domain re-verification occurs before an intrusive ScanTask is queued. The outcome is recorded in `domain_verification`, `domain_verification_challenge`, and `audit_event`, not in a separate authorization table. +6. Current MFA assurance is read from the identity-provider session or token. It is not persisted as a User boolean. +7. A ScanJob containing an intrusive ScanTask requires two current StatementResponse rows bound to that ScanJob: an attestation for `scan_target_permission` and an acceptance for `intrusive_scan_risk_liability`. +8. All v4.0 domain ScanTasks are non-intrusive. File ScanTasks use `classification = not_applicable`. +9. Guest ScanJobs are not shown in persistent history unless they are claimed after registration. +10. On terminal completion, `scan_job.purge_at` is set to `finished_at + 12 months + 30 days` by default. An unclaimed guest job's `purge_at` is instead `created_at + 24 hours`, set at creation, and a successful claim recomputes the timestamp under the default rule. Scan data is hard-deleted at `purge_at` regardless of job status, with unfinished work terminated first; the application sends notice 30 days beforehand, and that notice applies to a guest job only once it is claimed. Uploaded file bytes are purged after analysis or within 24 hours. Audit events are retained for 24 months. +11. Report rows store generation and source-provenance metadata only. Rendered artifacts are generated from retained scan data. After the source is purged, the metadata may remain, but another report cannot be generated from that source. +12. Finding regression comparison uses stable `check_id` values and, where required, normalized `affected_resource` values. A change to a `check_id` is a breaking result-schema change. +13. Deleting an AppUser cascades `user_key_envelope`, notifications, and other user-owned operational data, sets organization-owned attribution references to null, and leaves no plain AppUser identifier in retained StatementResponse or AuditEvent rows. +14. Invitation acceptance requires a matching verified email and atomically assigns the invitation organization and role. An existing member of another organization cannot accept. +15. `scan_task.id` is the queue task identifier. Durable cancellation intent is stored in `scan_task.cancellation_requested_at`. A canceled task cannot later produce an accepted successful result. + +## 14. Row-level check constraints + +Every constraint below enforces an invariant already stated in the table sections, and each is expressible as a PostgreSQL `CHECK` on one row, so the DDL carries it instead of application discipline. Between two null tests, `=` reads "exactly when". Where §3.5 erasure nulls an attribution column, the pairing is one-way instead: the actor implies the time, never the reverse, so erasing a user cannot violate the constraint. Cross-row and cross-table rules stay in §13. + +| Table | Constraint | Enforces | +|---------------------------------|------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------| +| `organization_invitation` | `accepted_by_user_id IS NULL OR accepted_at IS NOT NULL` | an acceptance actor implies an acceptance time | +| `asset` | `parent_asset_id IS NULL OR origin = 'discovered'` | only discovery produces child assets | +| `domain_verification_challenge` | `failure_code IS NULL OR last_recheck_at IS NOT NULL` | a failure code always follows a recorded check | +| `file_upload` | `(purged_at IS NULL) = (storage_key IS NOT NULL)` | the storage reference exists exactly while the bytes do | +| `file_upload` | `uploaded_by_user_id IS NULL OR organization_id IS NOT NULL` | a known uploader implies a known organization | +| `file_upload` | `purge_due_at <= uploaded_at + interval '24 hours'` | the purge deadline is at most 24 hours after upload | +| `scan_job` | `num_nonnulls(asset_id, target_domain, file_upload_id) = 1` | exactly one launch target | +| `scan_job` | `(source = 'schedule') = (schedule_id IS NOT NULL)` | schedule provenance | +| `scan_job` | `(source = 'api') = (api_key_id IS NOT NULL)` | API-key provenance | +| `scan_job` | `target_domain IS NULL OR source = 'guest'` | free target text exists only on guest jobs | +| `scan_job` | `claim_token_hash IS NULL OR source = 'guest'` | only guest jobs are claimable | +| `scan_job` | `source <> 'guest' OR claimed_at IS NOT NULL OR claim_token_hash IS NOT NULL` | an unclaimed guest job always holds the claim hash | +| `scan_job` | `claimed_by_user_id IS NULL OR claimed_at IS NOT NULL` | a claim actor implies a claim time | +| `scan_job` | `claimed_at IS NULL OR organization_id IS NOT NULL` | a claimed job always has an organization | +| `scan_job` | `claimed_at IS NULL OR claim_token_hash IS NULL` | the hash is discarded on claim | +| `scan_job` | `organization_id IS NOT NULL OR source = 'guest'` | only guest jobs lack an organization | +| `scan_job` | `(status IN ('completed', 'partial', 'failed', 'canceled')) = (finished_at IS NOT NULL)` | terminal state and finish time agree | +| `scan_job` | `status <> 'running' OR started_at IS NOT NULL` | a running job has started | +| `scan_job` | `(purge_at IS NOT NULL) = (status IN ('completed', 'partial', 'failed', 'canceled') OR (source = 'guest' AND claimed_at IS NULL))` | the deadline exists exactly on terminal jobs and unclaimed guest jobs | +| `scan_task` | `num_nonnulls(target_asset_id, target_domain, file_upload_id) = 1` | exactly one task target | +| `scan_task` | `status <> 'blocked' OR status_reason IS NOT NULL` | blocked always says why | +| `scan_task` | `(module = 'file') = (classification = 'not_applicable')` | `not_applicable` belongs to File tasks alone | +| `scan_task` | `(file_upload_id IS NOT NULL) = (module = 'file')` | a File task targets an upload, and nothing else does | +| `scan_task` | `(status IN ('completed', 'failed', 'skipped', 'blocked', 'canceled')) = (finished_at IS NOT NULL)` | terminal state and finish time agree | +| `scan_task` | `status <> 'running' OR started_at IS NOT NULL` | a running task has started | +| `statement_response` | `(context_type IS NULL) = (context_id IS NULL)` | a context is named and bound together | +| `report` | `num_nonnulls(source_scan_job_id, source_scan_task_id) = 1` | exactly one source | +| `report` | `tier = 'technical' OR technical_view IS NULL` | view depth applies to technical reports alone | +| `api_key` | `revocation_reason IS NULL OR revoked_at IS NOT NULL` | a reason always accompanies a revocation | +| `audit_event` | `num_nonnulls(payload_encrypted, wrapped_dek, envelope_id, encryption_metadata) IN (0, 4)` | the encrypted-payload column group is all-or-none | +| `audit_event` | `detail IS NOT NULL OR payload_encrypted IS NOT NULL` | an event carries detail, an encrypted payload, or both | + +Uniqueness rules that need a partial or expression index rather than a plain constraint: + +- `organization_invitation`: `UNIQUE (organization_id, lower(email)) WHERE accepted_at IS NULL AND revoked_at IS NULL` — one live invitation per organization and normalized address. Expiry cannot sit in an index predicate, so replacing an expired invitation first sets `revoked_at` — the resend rule the API design already states. +- `statement_response`: `UNIQUE (statement_id, envelope_id) WHERE context_type IS NULL` and `UNIQUE (statement_id, context_type, context_id) WHERE context_type IS NOT NULL` — the two response-uniqueness rules of §5.2. + +Stated rules that stay outside `CHECK` reach, enforced by triggers or application logic: the verification target's `asset_type = domain` (§4.2, cross-table), grade presence per test (§8.1, catalog-owned), same-organization foreign keys (§13.1), and `source = manual` requiring a triggering user at creation (§7.1, temporal — erasure may null it later). + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..27abdad --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "nc3-testing-platform" +version = "0.1.0" +description = "v4.0 backend MVP for the NC3 Testing Platform." +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "fastapi[standard]>=0.139.2", + "idna>=3.18", + "pydantic[email]>=2.13.4", +] + +[project.scripts] +export-openapi = "nc3_testing_platform.tools.export_openapi:main" + +[dependency-groups] +dev = [ + "httpx2>=2.9.1", + "openapi-spec-validator>=0.9.0", + "pyright>=1.1.411", + "pytest>=9.1.1", + "ruff>=0.16.0", +] + +[build-system] +requires = ["uv_build>=0.11.30,<0.12.0"] +build-backend = "uv_build" + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "D", "ERA", "TD"] +ignore = ["D105", "D107", "TD002", "TD003"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.pyright] +include = ["src", "tests"] diff --git a/src/nc3_testing_platform/__init__.py b/src/nc3_testing_platform/__init__.py new file mode 100644 index 0000000..1c5cc0f --- /dev/null +++ b/src/nc3_testing_platform/__init__.py @@ -0,0 +1 @@ +"""v4.0 backend MVP for the NC3 Testing Platform.""" diff --git a/src/nc3_testing_platform/core/__init__.py b/src/nc3_testing_platform/core/__init__.py new file mode 100644 index 0000000..a52fb6c --- /dev/null +++ b/src/nc3_testing_platform/core/__init__.py @@ -0,0 +1 @@ +"""Cross-domain building blocks shared by every domain package.""" diff --git a/src/nc3_testing_platform/core/config.py b/src/nc3_testing_platform/core/config.py new file mode 100644 index 0000000..1f31ab0 --- /dev/null +++ b/src/nc3_testing_platform/core/config.py @@ -0,0 +1,24 @@ +"""Values that a deployment can change without a code change.""" + +import os +from datetime import timedelta + +# How long a domain-verification challenge stays answerable; a verification that already succeeded is unaffected. +# Seven days: long enough for a DNS change to clear a ticketing process, short enough that an abandoned challenge does not sit open forever. +VERIFICATION_TOKEN_TTL = timedelta( + days=int(os.getenv("VERIFICATION_TOKEN_TTL_DAYS", "7")) +) + +# Vendor prefix in the DNS challenge record name, giving `_-verify.`. +# A generic name like `_verify` would collide when a domain owner verifies with two providers that both chose it. +# Set before issuing the first challenge: a later change breaks every challenge in flight and every record already-verified domains have published. +VERIFICATION_RECORD_PREFIX = os.getenv("VERIFICATION_RECORD_PREFIX", "nc3") + +# How far one retention extension moves a scan's `purge_at`. +# Data-protection policy varies by jurisdiction, so it stays out of the contract. +RETENTION_EXTENSION = timedelta(days=int(os.getenv("RETENTION_EXTENSION_DAYS", "365"))) + + +def verification_record_name(domain: str) -> str: + """The DNS name at which a domain's challenge token must be published.""" + return f"_{VERIFICATION_RECORD_PREFIX}-verify.{domain}" diff --git a/src/nc3_testing_platform/core/enums.py b/src/nc3_testing_platform/core/enums.py new file mode 100644 index 0000000..4b112f6 --- /dev/null +++ b/src/nc3_testing_platform/core/enums.py @@ -0,0 +1,209 @@ +"""Closed enumerations of the v4 contract. + +Every enum mirrors a PostgreSQL enum type in the data model, with two exceptions. +`VerificationStatus` and `TrendDirection` are computed at read time from stored rows and have no column of their own. + +Absent or deferred values: +- `report_format` has no Atom member. RSS and Atom belong to `feed_format`, which + is a property of a per-asset feed, not of a generated report. +- `asset_type` has only `domain`. IP and CIDR assets carry a different attestation +model and are deferred to v4.1. + +`test_key`, `check_id`, `status_reason`, and `notification.type` are namespaced +text, not enums: the application code owns their vocabulary. +""" + +from enum import StrEnum + + +class OrganizationRole(StrEnum): + """Role within one organization.""" + + MEMBER = "member" + ORGANIZATION_ADMIN = "organization_admin" + + +class AssetType(StrEnum): + """v4.0 assets are currently domains.""" + + DOMAIN = "domain" + + +class AssetOrigin(StrEnum): + """Whether the asset was registered by a user or found by subdomain discovery.""" + + ADDED = "added" + DISCOVERED = "discovered" + + +class VerificationStatus(StrEnum): + """Current state of a domain-ownership challenge.""" + + PENDING = "pending" + VERIFIED = "verified" + EXPIRED = "expired" + + +class VerificationScope(StrEnum): + """Verification coverage. Zone coverage is evaluated by DNS-label ancestry.""" + + EXACT = "exact" + ZONE = "zone" + + +class DnsRecordType(StrEnum): + """DNS record a verification challenge is published as. + + TXT is the only v4.0 method. + """ + + TXT = "TXT" + + +class StatementResponseKind(StrEnum): + """Acceptance and attestation share a receipt shape but are distinct acts.""" + + ACCEPTANCE = "acceptance" + ATTESTATION = "attestation" + + +class ScanSource(StrEnum): + """What requested a scan job. + + Derived from the request context, never supplied by the caller. + """ + + GUEST = "guest" + MANUAL = "manual" + SCHEDULE = "schedule" + API = "api" + + +class ScanJobStatus(StrEnum): + """Job lifecycle. `partial` means usable results exist alongside failures.""" + + QUEUED = "queued" + RUNNING = "running" + COMPLETED = "completed" + PARTIAL = "partial" + FAILED = "failed" + CANCELED = "canceled" + + +class ScanModule(StrEnum): + """The five v4.0 test modules.""" + + EMAIL = "email" + WEB = "web" + FILE = "file" + PQC = "pqc" + DNSSEC = "dnssec" + + +class ScanClassification(StrEnum): + """Intrusiveness of one executable test, copied onto the task at creation. + + `not_applicable` is used only by File tests. + """ + + NON_INTRUSIVE = "non_intrusive" + INTRUSIVE = "intrusive" + NOT_APPLICABLE = "not_applicable" + + +class ScanTaskStatus(StrEnum): + """Task lifecycle. + + `blocked` always carries a `status_reason` explaining why, + so the UI can tell the user what stopped a check. + """ + + QUEUED = "queued" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + SKIPPED = "skipped" + BLOCKED = "blocked" + CANCELED = "canceled" + + +class ScanGrade(StrEnum): + """Letter grade. Produced by the scan modules.""" + + A_PLUS = "A+" + A = "A" + B = "B" + C = "C" + D = "D" + F = "F" + + +class TrendDirection(StrEnum): + """Movement of a score against the previous comparable measurement.""" + + IMPROVING = "improving" + UNCHANGED = "unchanged" + DECLINING = "declining" + + +class FindingSeverity(StrEnum): + """Severity values of one finding.""" + + CRITICAL = "critical" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + INFO = "info" + + +class FindingStatus(StrEnum): + """Historical-comparison classification, derived at result time and immutable.""" + + NEW = "new" + REGRESSION = "regression" + PERSISTENT = "persistent" + RESOLVED = "resolved" + + +class ApiKeyScope(StrEnum): + """Capability granted by an API key.""" + + READ_ONLY = "read_only" + FULL_SCAN = "full_scan" + + +class ReportTier(StrEnum): + """Audience of a generated report.""" + + EXECUTIVE = "executive" + TECHNICAL = "technical" + + +class TechnicalReportView(StrEnum): + """Depth of a technical report. Meaningful only when the evidence tier is technical.""" + + FULL = "full" + SUMMARY = "summary" + + +class ReportFormat(StrEnum): + """Rendered report document formats.""" + + PDF = "pdf" + DOCX = "docx" + JSON = "json" + + +class ReportLanguage(StrEnum): + """Report output language.""" + + EN = "en" + FR = "fr" + DE = "de" + + +class FeedFormat(StrEnum): + """Syndication format of a per-asset feed.""" + + RSS = "rss" + ATOM = "atom" diff --git a/src/nc3_testing_platform/core/errors.py b/src/nc3_testing_platform/core/errors.py new file mode 100644 index 0000000..54a3130 --- /dev/null +++ b/src/nc3_testing_platform/core/errors.py @@ -0,0 +1,211 @@ +"""RFC 9457 `application/problem+json` error contract. + +Every error response references `ProblemDetail`. Handlers emit it at runtime with +the correct media type; a custom OpenAPI pass relabels the documented media type +to `application/problem+json` while keeping the schema `$ref`'d in components. +""" + +import json +from collections.abc import Iterator +from http import HTTPStatus + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field +from starlette.exceptions import HTTPException as StarletteHTTPException + +PROBLEM_MEDIA_TYPE = "application/problem+json" +_PROBLEM_REF = "#/components/schemas/ProblemDetail" +_VALIDATION_REF = "#/components/schemas/HTTPValidationError" +_FASTAPI_VALIDATION_SCHEMAS = ("HTTPValidationError", "ValidationError") + + +class FieldError(BaseModel): + """One field-level validation error (RFC 9457 extension member).""" + + name: str = Field( + description="Dotted path to the offending field, e.g. `body.email`." + ) + reason: str + + +class ProblemDetail(BaseModel): + """RFC 9457 problem detail.""" + + type: str = Field( + default="about:blank", + description="URI reference identifying the problem type.", + ) + title: str = Field(description="Short, human-readable summary of the problem type.") + status: int = Field(description="HTTP status code.") + detail: str | None = Field( + default=None, description="Human-readable explanation for this occurrence." + ) + instance: str | None = Field( + default=None, description="URI reference identifying this occurrence." + ) + errors: list[FieldError] | None = Field( + default=None, description="Field-level validation errors (extension)." + ) + + +class ProblemResponse(JSONResponse): + """JSON response that carries the `application/problem+json` media type.""" + + media_type = PROBLEM_MEDIA_TYPE + + +def problem_responses(*status_codes: int) -> dict[int | str, dict]: + """Build an OpenAPI `responses` map where each code references `ProblemDetail`. + + Attach to a route via ``responses=problem_responses(404, 409)``. The default + media type is rewritten to `application/problem+json` by :func:`configure_openapi`. + """ + return { + code: { + "model": ProblemDetail, + "description": HTTPStatus(code).phrase, + "content": {PROBLEM_MEDIA_TYPE: {}}, + } + for code in status_codes + } + + +async def _http_exception_handler( + request: Request, exc: StarletteHTTPException +) -> ProblemResponse: + problem = ProblemDetail( + title=HTTPStatus(exc.status_code).phrase, + status=exc.status_code, + detail=exc.detail if isinstance(exc.detail, str) else None, + instance=str(request.url), + ) + return ProblemResponse( + status_code=exc.status_code, + content=problem.model_dump(mode="json", exclude_none=True), + headers=getattr(exc, "headers", None), + ) + + +async def _validation_exception_handler( + request: Request, exc: RequestValidationError +) -> ProblemResponse: + errors = [ + FieldError(name=".".join(str(part) for part in err["loc"]), reason=err["msg"]) + for err in exc.errors() + ] + problem = ProblemDetail( + title=HTTPStatus.UNPROCESSABLE_ENTITY.phrase, + status=HTTPStatus.UNPROCESSABLE_ENTITY, + detail="Request validation failed.", + instance=str(request.url), + errors=errors, + ) + return ProblemResponse( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + content=problem.model_dump(mode="json", exclude_none=True), + ) + + +async def _unhandled_exception_handler( + request: Request, exc: Exception +) -> ProblemResponse: + # No `detail`: the exception text is for the server log, never for the client. + problem = ProblemDetail( + title=HTTPStatus.INTERNAL_SERVER_ERROR.phrase, + status=HTTPStatus.INTERNAL_SERVER_ERROR, + instance=str(request.url), + ) + return ProblemResponse( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + content=problem.model_dump(mode="json", exclude_none=True), + ) + + +def register_exception_handlers(app: FastAPI) -> None: + """Route HTTP, validation, and unhandled errors through the problem+json handlers.""" + # `exc` annotations are narrower (more precise), which trips pyright's contravariance check. + app.add_exception_handler(StarletteHTTPException, _http_exception_handler) # pyright: ignore[reportArgumentType] + app.add_exception_handler(RequestValidationError, _validation_exception_handler) # pyright: ignore[reportArgumentType] + # Starlette re-raises after this handler responds, so the traceback still reaches the server log. + app.add_exception_handler(Exception, _unhandled_exception_handler) + + +def _responses(schema: dict) -> Iterator[dict]: + """Every response object in the document.""" + for path_item in schema.get("paths", {}).values(): + for operation in path_item.values(): + if not isinstance(operation, dict): + continue + for response in operation.get("responses", {}).values(): + if isinstance(response, dict): + yield response + + +def _relabel_problem_media_type(schema: dict) -> None: + """In-place: move ProblemDetail error bodies from application/json to problem+json.""" + for response in _responses(schema): + content = response.get("content") + if not content: + continue + json_body = content.get("application/json") + if json_body and json_body.get("schema", {}).get("$ref") == _PROBLEM_REF: + content[PROBLEM_MEDIA_TYPE] = content.pop("application/json") + + +def _replace_default_validation_body(schema: dict) -> None: + """In-place: restate FastAPI's generated 422 body as a problem detail. + + Without this the contract claims two different error shapes — problem+json for + every error we declare, and FastAPI's `HTTPValidationError` for validation + failures — and a generated client would need branches for both. + """ + for response in _responses(schema): + content = response.get("content") + if not content: + continue + json_body = content.get("application/json") + if json_body and json_body.get("schema", {}).get("$ref") == _VALIDATION_REF: + content.pop("application/json") + content[PROBLEM_MEDIA_TYPE] = {"schema": {"$ref": _PROBLEM_REF}} + response["description"] = HTTPStatus.UNPROCESSABLE_ENTITY.phrase + + +def _prune_unreferenced_schemas(schema: dict, names: tuple[str, ...]) -> None: + """Drop component schemas that nothing references anymore. + + After the HTTP 422 response bodies are rewritten to problem+json, FastAPI's + validation models are referenced by nothing; left in place, they become dead + types in every generated client. + `HTTPValidationError` holds the only reference to `ValidationError`, so it + must be removed first — only then does `ValidationError` count as unreferenced. + """ + components = schema.get("components", {}).get("schemas") + if not components: + return + for name in names: + if name not in components: + continue + remaining = { + "paths": schema.get("paths", {}), + "schemas": {k: v for k, v in components.items() if k != name}, + } + if f'"#/components/schemas/{name}"' not in json.dumps(remaining): + del components[name] + + +def configure_openapi(app: FastAPI) -> None: + """Custom OpenAPI generator that emits problem+json for error bodies.""" + default_openapi = app.openapi + + def openapi() -> dict: + schema = default_openapi() # FastAPI caches into app.openapi_schema + # Each pass is idempotent: no application/json body carries a ProblemDetail + # or HTTPValidationError reference, so re-running is a no-op. + _relabel_problem_media_type(schema) + _replace_default_validation_body(schema) + _prune_unreferenced_schemas(schema, _FASTAPI_VALIDATION_SCHEMAS) + return schema + + app.openapi = openapi diff --git a/src/nc3_testing_platform/core/openapi.py b/src/nc3_testing_platform/core/openapi.py new file mode 100644 index 0000000..ad6bd44 --- /dev/null +++ b/src/nc3_testing_platform/core/openapi.py @@ -0,0 +1,38 @@ +"""Registers models missing from FastAPI's OpenAPI generation. + +FastAPI adds a model to the document's `components.schemas` only when a route +uses it as a request body or response type. A model referenced only from a +handwritten `openapi_extra` schema is missed — its `$ref` dangles and client +generators emit untyped placeholders. +""" + +from fastapi import FastAPI +from pydantic import BaseModel +from pydantic.json_schema import models_json_schema + +_REF_TEMPLATE = "#/components/schemas/{model}" + + +def register_component_schemas(app: FastAPI, *models: type[BaseModel]) -> None: + """Ensures each model, and every model nested inside it, has an entry in `components.schemas`. + + A model FastAPI already emitted keeps its original entry rather than being + replaced by a subtly different rendering. + """ + previous_openapi = app.openapi + + def openapi() -> dict: + schema = previous_openapi() + components = schema.setdefault("components", {}).setdefault("schemas", {}) + # `validation` mode: every model registered this way describes a request + # body or an event payload the client parses. Revisit if a registered model + # ever serializes differently from how it validates. + _, definitions = models_json_schema( + [(model, "validation") for model in models], + ref_template=_REF_TEMPLATE, + ) + for name, definition in definitions.get("$defs", {}).items(): + components.setdefault(name, definition) + return schema + + app.openapi = openapi diff --git a/src/nc3_testing_platform/core/pagination.py b/src/nc3_testing_platform/core/pagination.py new file mode 100644 index 0000000..cc6c0ea --- /dev/null +++ b/src/nc3_testing_platform/core/pagination.py @@ -0,0 +1,52 @@ +"""Cursor-based pagination. + +Cursors stay stable when rows are inserted between page reads; offsets skip or +repeat rows. + +Pagination is exposed as a dependency (`CursorPage`) rather than a query-model, so +it composes with per-endpoint filters. (A `Annotated[Model, Query()]` param +stops expanding once other scalar query params sit alongside it.) + + @router.get("") + async def list_things(page: CursorPage, status: Status | None = None) -> Page[Thing]: + ... +""" + +from typing import Annotated + +from fastapi import Depends, Query +from pydantic import BaseModel, Field + + +class CursorParams(BaseModel): + """Resolved cursor and limit for a paginated list request.""" + + cursor: str | None = None + limit: int = 50 + + +def cursor_params( + cursor: Annotated[ + str | None, + Query( + description="Opaque cursor returned as `next_cursor` by the previous page." + ), + ] = None, + limit: Annotated[int, Query(ge=1, le=100, description="Max items per page.")] = 50, +) -> CursorParams: + """Resolves the `cursor` and `limit` query parameters into `CursorParams`.""" + return CursorParams(cursor=cursor, limit=limit) + + +# Route dependency: injects resolved `CursorParams` and documents `cursor` + `limit`. +CursorPage = Annotated[CursorParams, Depends(cursor_params)] + + +class Page[T](BaseModel): + """One page of a cursor-paginated list.""" + + items: list[T] + next_cursor: str | None = Field( + default=None, + description="Cursor for the next page; `null` when there are no more results.", + ) diff --git a/src/nc3_testing_platform/core/schemas.py b/src/nc3_testing_platform/core/schemas.py new file mode 100644 index 0000000..45aab93 --- /dev/null +++ b/src/nc3_testing_platform/core/schemas.py @@ -0,0 +1,74 @@ +"""Shared base model and value objects used by more than one domain. + +Wire field names match the database column names exactly (`organization_id`, not `org_id`), so the database, the +contract, and generated clients share one vocabulary. +""" + +from datetime import UTC, datetime +from typing import Annotated + +import idna +from pydantic import ( + UUID7, + AfterValidator, + AwareDatetime, + BaseModel, + ConfigDict, + Field, +) + +# Wire values are UUIDv7, which is time-sortable. +ResourceId = UUID7 + + +# Every timestamp in the contract is a UTC instant serialized as ISO-8601. +def _normalize_to_utc(value: datetime) -> datetime: + return value.astimezone(UTC) + +Timestamp = Annotated[ + AwareDatetime, + AfterValidator(_normalize_to_utc), +] + + +def _parse_domain_name(value: str) -> str: + """Canonicalizes a domain to lowercase IDNA A-label form without a trailing dot. + + Accepts Unicode or ASCII input. + Raises `ValueError` for anything that does not parse as a domain. + """ + try: + canonical = idna.encode(value, uts46=True).decode("ascii").removesuffix(".") + except idna.IDNAError as error: + raise ValueError(f"Not a valid domain name: {error}") from error + if "." not in canonical: + raise ValueError("A domain name needs at least two labels.") + if len(canonical) > 253: + raise ValueError("A domain name is at most 253 characters in A-label form.") + return canonical + + +# A domain in canonical form: lowercase IDNA A-labels, no trailing dot. Parsing +# happens at the boundary, so `asset.value` and the `target_domain` columns never +# hold a non-canonical spelling that would defeat their uniqueness rules. +DomainName = Annotated[str, AfterValidator(_parse_domain_name)] + + +class BaseSchema(BaseModel): + """Base for response models.""" + + model_config = ConfigDict(from_attributes=True) + + +class SeverityCounts(BaseModel): + """Findings counted by severity band. + + Stored as untyped JSONB in `scan_result.severity_counts`, but the key set is fully determined by `finding_severity`, so the contract types it. + Non-graded tests summarize outcomes through these counts instead of a letter grade; graded tests carry both. + """ + + critical: int = Field(default=0, ge=0, description="Findings in the critical band.") + high: int = Field(default=0, ge=0, description="Findings in the high band.") + medium: int = Field(default=0, ge=0, description="Findings in the medium band.") + low: int = Field(default=0, ge=0, description="Findings in the low band.") + info: int = Field(default=0, ge=0, description="Findings in the informational band.") diff --git a/src/nc3_testing_platform/core/security.py b/src/nc3_testing_platform/core/security.py new file mode 100644 index 0000000..d82b297 --- /dev/null +++ b/src/nc3_testing_platform/core/security.py @@ -0,0 +1,145 @@ +"""OpenAPI security schemes and the rate-limit response contract. + +Contract-only, the identity provider itself is external. + +The identity provider owns identity, credentials, authentication methods, +sessions, MFA enrollment, and current assurance; this service only projects +an `app_user` row from a verified subject. A caller therefore presents +either an OIDC token or a platform API key. + +`auto_error` is off throughout, authentication gates are server-side and +evaluated per operation. + +Developer note: A frontend may route calls through its own proxy backend ("BFF"). +That changes how the browser authenticates to the BFF (httpOnly session cookie), +not this contract: the BFF calls this API as an ordinary client, presenting an +OIDC bearer token or an API key. +""" + +import os +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from fastapi.security import APIKeyHeader, OpenIdConnect + +from nc3_testing_platform.core.errors import PROBLEM_MEDIA_TYPE, ProblemDetail + +OIDC_DISCOVERY_URL = os.getenv( + "OIDC_DISCOVERY_URL", + "https://idp.example.invalid/.well-known/openid-configuration", +) + +oidc = OpenIdConnect( + openIdConnectUrl=OIDC_DISCOVERY_URL, + scheme_name="OpenIdConnect", + auto_error=False, + description=( + "OpenID Connect token issued by the platform identity provider. Some " + "operations additionally require current MFA assurance, read from the " + "token at request time." + ), +) + +api_key = APIKeyHeader( + name="Authorization", + scheme_name="ApiKey", + auto_error=False, + description=( + "Platform API key: `Authorization: Bearer `. Scopes are `read_only` " + "and `full_scan`. A scan launched with a key is recorded with " + "`source = api`. Creating or revoking a key requires an OpenID Connect " + "token with current MFA assurance." + ), +) + +OidcAuth = Annotated[str | None, Depends(oidc)] +ApiKeyAuth = Annotated[str | None, Depends(api_key)] + + +def require_authentication(oidc_token: OidcAuth, key: ApiKeyAuth) -> None: + """Reject a caller presenting neither credential. + + Belongs on the operation. + `POST /scans` accepts anonymous callers, and a guest reads their own scan + with a token instead of a credential. + """ + if not (oidc_token or key): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Present an OpenID Connect token or a platform API key.", + ) + + +# Attach as `dependencies=[Authenticated]` on an operation. +Authenticated = Depends(require_authentication) + + +def require_oidc_token(oidc_token: OidcAuth) -> None: + """Rejects a caller without an OpenID Connect token. + + Belongs on an operation that consumes current MFA assurance. + Assurance is read from the identity provider's token, so a platform API key cannot satisfy the gate and the operation declares only the OIDC scheme. + """ + if not oidc_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Present an OpenID Connect token carrying current MFA assurance.", + ) + + +# Attach as `dependencies=[MfaGated]` on an operation. +MfaGated = Depends(require_oidc_token) + + +def read_optional_credentials(oidc_token: OidcAuth, key: ApiKeyAuth) -> None: + """Declares both credentials without requiring either. + + The parameter list is the entire effect: + FastAPI reads the two credential dependencies and publishes both schemes into the operation's `security`. + The body applies no gate, because a guest presents a claim token in place of a credential. + + Belongs on an operation that an owner reaches with a credential and a guest reaches with a claim token. + """ + + +# Attach as `dependencies=[OptionallyAuthenticated]`, together with +# ANONYMOUS_ALTERNATIVE in `openapi_extra`, so the operation declares both schemes +# and the anonymous alternative. +OptionallyAuthenticated = Depends(read_optional_credentials) + +# Adds the anonymous alternative to an operation's `security`, marking it callable +# without credentials. +ANONYMOUS_ALTERNATIVE: list[dict[str, list[str]]] = [{}] + +# IETF-draft rate-limit headers, surfaced on quota-bearing responses. Counters +# themselves live in the anti-abuse subsystem, outside this model. +RATE_LIMIT_HEADERS: dict[str, dict] = { + "RateLimit": { + "schema": {"type": "string"}, + "description": "Quota-window state, e.g. `limit=100, remaining=42, reset=30`.", + }, + "RateLimit-Policy": { + "schema": {"type": "string"}, + "description": "Advertised quota policy, e.g. `100;w=60`.", + }, + "Retry-After": { + "schema": {"type": "integer"}, + "description": "Seconds until the quota resets. Sent with `429`.", + }, +} + + +def rate_limited() -> dict[int | str, dict]: + """A `429` response carrying the rate-limit headers and a problem body. + + Merge into the ``responses`` of any operation under a guest, user, or + organization quota. + """ + return { + 429: { + "model": ProblemDetail, + "description": "Too Many Requests", + "content": {PROBLEM_MEDIA_TYPE: {}}, + "headers": RATE_LIMIT_HEADERS, + } + } diff --git a/src/nc3_testing_platform/domains/__init__.py b/src/nc3_testing_platform/domains/__init__.py new file mode 100644 index 0000000..bc33287 --- /dev/null +++ b/src/nc3_testing_platform/domains/__init__.py @@ -0,0 +1 @@ +"""Feature domains, one package per API resource area.""" diff --git a/src/nc3_testing_platform/domains/admin/__init__.py b/src/nc3_testing_platform/domains/admin/__init__.py new file mode 100644 index 0000000..c2c515b --- /dev/null +++ b/src/nc3_testing_platform/domains/admin/__init__.py @@ -0,0 +1 @@ +"""Platform-administration operations.""" diff --git a/src/nc3_testing_platform/domains/admin/router.py b/src/nc3_testing_platform/domains/admin/router.py new file mode 100644 index 0000000..cc7d8a8 --- /dev/null +++ b/src/nc3_testing_platform/domains/admin/router.py @@ -0,0 +1,77 @@ +"""Platform-administration operations. + +Gated on the platform-administrator claim from the identity provider, which is independent of +any organization role — an organization administrator has no access here. +""" + +from datetime import UTC, datetime +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Query + +from nc3_testing_platform.core.errors import problem_responses +from nc3_testing_platform.core.pagination import CursorPage, Page +from nc3_testing_platform.core.schemas import ResourceId +from nc3_testing_platform.core.security import Authenticated +from nc3_testing_platform.domains.admin.schemas import AuditEvent +from nc3_testing_platform.domains.scans.examples import ASSET_ID, ORGANIZATION_ID + +router = APIRouter( + prefix="/admin", + tags=["admin"], +) + +_EVENT_ID = UUID("019ee1a9-0011-7a22-8b33-4c44d5e66f77") + + +@router.get( + "/audit-events", + summary="Read the audit log", + responses=problem_responses(401, 403), + dependencies=[Authenticated], +) +async def list_audit_events( + page: CursorPage, + chain_id: Annotated[str | None, Query(description="Restrict to one chain.")] = None, + organization_id: Annotated[ + ResourceId | None, Query(description="Restrict to one organization's chain.") + ] = None, + event_type: Annotated[ + str | None, Query(description="Exact namespaced event type.") + ] = None, + occurred_after: Annotated[ + datetime | None, Query(description="Inclusive lower bound on `occurred_at`.") + ] = None, + occurred_before: Annotated[ + datetime | None, Query(description="Exclusive upper bound on `occurred_at`.") + ] = None, +) -> Page[AuditEvent]: + """Audit entries in chain order. + + Ordered by (`chain_id`, `sequence_number`) rather than by time, because that + pair is what defines the hash chain — reading in timestamp order would not let + a verifier follow `previous_hash` from one entry to the next. + + Encrypted payloads are returned as ciphertext. Decryption is a separate operator + procedure and is not exposed by v4.0. + """ + return Page( + items=[ + AuditEvent( + id=_EVENT_ID, + organization_id=ORGANIZATION_ID, + chain_id=f"org:{ORGANIZATION_ID}", + sequence_number=4712, + event_type="asset.verification.succeeded", + subject_type="asset", + subject_id=ASSET_ID, + detail={"verified_scope": "zone", "attempt": 2}, + occurred_at=datetime(2026, 7, 31, 8, 30, tzinfo=UTC), + previous_hash="sha256:1b4a7f0c3d6e9b2a5f8c1d4e7b0a3f6c5d8e1b4a7f0c3d6e", + entry_hash="sha256:9b2a5f8c1d4e7b0a3f6c5d8e1b4a7f0c3d6e9b2a5f8c1d4e", + retention_until=datetime(2028, 7, 31, 8, 30, tzinfo=UTC), + ) + ], + next_cursor=None, + ) diff --git a/src/nc3_testing_platform/domains/admin/schemas.py b/src/nc3_testing_platform/domains/admin/schemas.py new file mode 100644 index 0000000..d657331 --- /dev/null +++ b/src/nc3_testing_platform/domains/admin/schemas.py @@ -0,0 +1,87 @@ +"""Platform-administrator view of the audit log. + +An audit row never names a user in the clear. Identity, IP address, and user agent +live inside `payload_encrypted`, sealed with a per-event key that is itself wrapped +by a per-user key. Erasing a user deletes that per-user key, at which point the +payload becomes permanently unreadable while the row and its hash chain stay +intact — the log remains provably unbroken without retaining the person. + +The ciphertext and wrapping fields are returned even though v4.0 never decrypts +them, because `entry_hash` is computed over them. Withholding them would leave a +reader unable to verify the chain they were given. +""" + +from typing import Any + +from pydantic import Field + +from nc3_testing_platform.core.schemas import BaseSchema, ResourceId, Timestamp + + +class AuditEvent(BaseSchema): + """One append-only audit entry.""" + + id: ResourceId + organization_id: ResourceId | None = Field( + default=None, description="Null for platform-chain events." + ) + chain_id: str = Field( + description=( + "Organization or platform chain this entry belongs to. Never a per-user " + "chain: chain membership would itself be a user identifier." + ) + ) + sequence_number: int = Field( + description="Position within the chain. Unique with `chain_id`." + ) + event_type: str = Field( + description="Namespaced event type. Vocabulary is code-owned.", + examples=["asset.verification.succeeded"], + ) + subject_type: str | None = Field( + default=None, description="Resource category. Never identifies a user." + ) + subject_id: ResourceId | None = Field( + default=None, description="Non-user resource this event concerns." + ) + # `detail` has no fixed schema; contents are per-event-type and code-owned. + detail: dict[str, Any] | None = Field( + default=None, + description=( + "Operational values only — status, counts, resource identifiers. " + "Identity, email, addresses, and domains never appear here." + ), + ) + payload_encrypted: str | None = Field( + default=None, + description=( + "Base64 ciphertext of the sensitive detail. Not decrypted by v4.0; " + "returned so the hash chain can be verified." + ), + ) + wrapped_dek: str | None = Field( + default=None, description="Base64 per-event key, wrapped by the user key." + ) + envelope_id: ResourceId | None = Field( + default=None, + description=( + "Opaque key-envelope reference. Carries no foreign key and encodes no " + "user identifier; deleting the envelope is what shreds the payload." + ), + ) + encryption_metadata: dict[str, Any] | None = Field( + default=None, description="Algorithm and nonce metadata for the payload." + ) + occurred_at: Timestamp + previous_hash: str | None = Field( + default=None, description="Null for the first entry in a chain." + ) + entry_hash: str = Field( + description=( + "Covers `previous_hash` and the whole stored entry, ciphertext and " + "envelope reference included, so swapping either breaks the chain." + ) + ) + retention_until: Timestamp = Field( + description="Twenty-four months after the event by default." + ) diff --git a/src/nc3_testing_platform/domains/api_keys/__init__.py b/src/nc3_testing_platform/domains/api_keys/__init__.py new file mode 100644 index 0000000..2455de9 --- /dev/null +++ b/src/nc3_testing_platform/domains/api_keys/__init__.py @@ -0,0 +1 @@ +"""API key management.""" diff --git a/src/nc3_testing_platform/domains/api_keys/router.py b/src/nc3_testing_platform/domains/api_keys/router.py new file mode 100644 index 0000000..c4b0716 --- /dev/null +++ b/src/nc3_testing_platform/domains/api_keys/router.py @@ -0,0 +1,96 @@ +"""API key management. + +Every operation here requires current MFA assurance, read from the OIDC token +at request time. +""" + +from datetime import UTC, datetime +from uuid import UUID + +from fastapi import APIRouter, status + +from nc3_testing_platform.core.enums import ApiKeyScope +from nc3_testing_platform.core.errors import problem_responses +from nc3_testing_platform.core.pagination import CursorPage, Page +from nc3_testing_platform.core.schemas import ResourceId +from nc3_testing_platform.core.security import Authenticated, MfaGated +from nc3_testing_platform.domains.api_keys.schemas import ( + ApiKey, + ApiKeyCreate, + ApiKeyCreated, + ApiKeyRevoke, +) +from nc3_testing_platform.domains.scans.examples import ORGANIZATION_ID, USER_ID + +router = APIRouter( + prefix="/api-keys", + tags=["api-keys"], +) + +_KEY_ID = UUID("019ee1a8-0011-7a22-8b33-4c44d5e66f77") +_T = datetime(2026, 7, 1, 12, 0, tzinfo=UTC) + + +def _sample_key(revoked: bool = False) -> ApiKey: + return ApiKey( + id=_KEY_ID, + organization_id=ORGANIZATION_ID, + owner_user_id=USER_ID, + created_by_user_id=USER_ID, + name="CI pipeline", + scope=ApiKeyScope.FULL_SCAN, + key_prefix="nc3_sk_live_7pL4", + revoked_at=_T if revoked else None, + revocation_reason="Rotated" if revoked else None, + last_used_at=datetime(2026, 7, 31, 8, 55, tzinfo=UTC), + created_at=_T, + ) + + +@router.get( + "", + summary="List API keys", + responses=problem_responses(401, 403), + dependencies=[Authenticated], +) +async def list_api_keys(page: CursorPage) -> Page[ApiKey]: + """Keys visible to the caller: their own, plus organization keys. + + Revoked keys stay listed. A key that once had access is part of the record of + who could reach what. + """ + return Page(items=[_sample_key()], next_cursor=None) + + +@router.post( + "", + status_code=status.HTTP_201_CREATED, + summary="Create an API key", + responses=problem_responses(401, 403, 422), + dependencies=[MfaGated], +) +async def create_api_key(body: ApiKeyCreate) -> ApiKeyCreated: + """Issue a key and return its secret once. + + Requires current MFA assurance. Creating an organization key additionally + requires the `organization_admin` role. + """ + return ApiKeyCreated( + **_sample_key().model_dump(), + secret="nc3_sk_live_7pL4vR8nT1mQ2xK9jH5gF3dS6aW0zY", + ) + + +@router.post( + "/{key_id}/revoke", + summary="Revoke an API key", + responses=problem_responses(401, 403, 404, 409), + dependencies=[MfaGated], +) +async def revoke_api_key(key_id: ResourceId, body: ApiKeyRevoke) -> ApiKey: + """Stop a key working while keeping its row. + + A `POST` rather than a `DELETE`, because `revoked_at` and the reason are the + point. Erasing an account also revokes and deletes that user's keys. + """ + return _sample_key(revoked=True) diff --git a/src/nc3_testing_platform/domains/api_keys/schemas.py b/src/nc3_testing_platform/domains/api_keys/schemas.py new file mode 100644 index 0000000..da7b8fa --- /dev/null +++ b/src/nc3_testing_platform/domains/api_keys/schemas.py @@ -0,0 +1,81 @@ +"""Programmatic access credentials. + +A key belongs either to one user or to the organization as a whole. A user key is +revoked when its owner is disabled or erased; an organization key is unaffected by +membership changes, so it suits long-lived automation. + +The plaintext secret exists in exactly one response and is never recoverable +afterward — only a lookup prefix and a hash are stored. +""" + +from pydantic import BaseModel, Field + +from nc3_testing_platform.core.enums import ApiKeyScope +from nc3_testing_platform.core.schemas import BaseSchema, ResourceId, Timestamp + + +class ApiKey(BaseSchema): + """A key's metadata and lifecycle.""" + + id: ResourceId + organization_id: ResourceId + owner_user_id: ResourceId | None = Field( + default=None, + description=( + "The owning user, or null for an organization key. Organization keys " + "require the `organization_admin` role to create." + ), + ) + created_by_user_id: ResourceId | None = None + name: str + scope: ApiKeyScope = Field( + description=( + "`read_only` permits `GET` operations. `full_scan` is additionally " + "required to launch a scan." + ) + ) + key_prefix: str = Field( + description="Non-secret prefix identifying the key in logs and in this list." + ) + expires_at: Timestamp | None = None + revoked_at: Timestamp | None = None + revocation_reason: str | None = None + last_used_at: Timestamp | None = None + created_at: Timestamp + + +class ApiKeyCreate(BaseModel): + """Issue a key.""" + + name: str = Field( + min_length=1, description="Human label, so a key can be recognized later." + ) + scope: ApiKeyScope + organization_key: bool = Field( + default=False, + description=( + "Issue a key owned by the organization rather than by the caller. " + "Requires the `organization_admin` role." + ), + ) + expires_at: Timestamp | None = Field( + default=None, description="Optional expiry. Absent means no expiry." + ) + + +class ApiKeyCreated(ApiKey): + """Creation response. The only place the secret ever appears.""" + + secret: str = Field( + description="Plaintext key. Shown once; only a hash is stored.", + examples=["nc3_sk_live_7pL4vR8nT1mQ2xK9jH5gF3dS6aW0zY"], + ) + + +class ApiKeyRevoke(BaseModel): + """Revoke a key, optionally recording why.""" + + revocation_reason: str | None = Field( + default=None, + description="Free-text note kept with the row for later investigation.", + ) diff --git a/src/nc3_testing_platform/domains/assets/__init__.py b/src/nc3_testing_platform/domains/assets/__init__.py new file mode 100644 index 0000000..d0057af --- /dev/null +++ b/src/nc3_testing_platform/domains/assets/__init__.py @@ -0,0 +1 @@ +"""Asset inventory, domain-ownership verification, and syndication feeds.""" diff --git a/src/nc3_testing_platform/domains/assets/examples.py b/src/nc3_testing_platform/domains/assets/examples.py new file mode 100644 index 0000000..fa7257c --- /dev/null +++ b/src/nc3_testing_platform/domains/assets/examples.py @@ -0,0 +1,135 @@ +"""Deterministic sample data for the assets domain. Fixed ids, fixed clock.""" + +from datetime import UTC, datetime +from uuid import UUID + +from nc3_testing_platform.core.config import ( + VERIFICATION_TOKEN_TTL, + verification_record_name, +) +from nc3_testing_platform.core.enums import ( + AssetOrigin, + AssetType, + FeedFormat, + VerificationScope, + VerificationStatus, +) +from nc3_testing_platform.domains.assets.schemas import ( + Asset, + AssetFeed, + AssetFeedCreated, + DomainVerification, + VerificationChallenge, +) +from nc3_testing_platform.domains.scans.examples import ( + ASSET_ID, + ORGANIZATION_ID, + USER_ID, +) + +_T0 = datetime(2026, 6, 1, 8, 30, tzinfo=UTC) +_T1 = datetime(2026, 7, 31, 9, 1, 12, tzinfo=UTC) + +_SUBDOMAIN_ASSET_ID = UUID("019ee1a3-0011-7a22-8b33-4c44d5e66f77") +_CHALLENGE_ID = UUID("019ee1a3-1122-7b33-9c44-5d55e6f77a88") +_FEED_ID = UUID("019ee1a3-2233-7c44-ad55-6e66f7a88b99") + + +def sample_asset() -> Asset: + """A registered, monitored domain.""" + return Asset( + id=ASSET_ID, + organization_id=ORGANIZATION_ID, + asset_type=AssetType.DOMAIN, + value="example.lu", + origin=AssetOrigin.ADDED, + created_by_user_id=USER_ID, + regression_alerts_enabled=True, + created_at=_T0, + updated_at=_T1, + ) + + +def sample_discovered_asset() -> Asset: + """A subdomain that discovery found, linked back to its parent. + + Included because `origin` and `parent_asset_id` only make sense together, and a + client that never sees a discovered asset will not handle one correctly. + """ + return Asset( + id=_SUBDOMAIN_ASSET_ID, + organization_id=ORGANIZATION_ID, + asset_type=AssetType.DOMAIN, + value="mail.example.lu", + origin=AssetOrigin.DISCOVERED, + parent_asset_id=ASSET_ID, + created_by_user_id=None, + created_at=_T1, + updated_at=_T1, + ) + + +def sample_challenge(checked: bool = False) -> VerificationChallenge: + """A zone-scoped challenge awaiting its DNS record. + + `checked` marks a challenge a lookup has just run against, which is what sets + `last_recheck_at` and the failure code beside it. + """ + return VerificationChallenge( + id=_CHALLENGE_ID, + requested_scope=VerificationScope.ZONE, + record_name=verification_record_name("example.lu"), + verification_token="verify-4f7a2c9e1b8d3056", + token_expires_at=_T0 + VERIFICATION_TOKEN_TTL, + requested_by_user_id=USER_ID, + requested_at=_T0, + last_recheck_at=_T1 if checked else None, + failure_code="dns.txt_record_not_found" if checked else None, + ) + + +def sample_verification( + status: VerificationStatus = VerificationStatus.VERIFIED, + checked: bool = False, +) -> DomainVerification: + """A zone-scoped verification in the given state.""" + verified = status == VerificationStatus.VERIFIED + return DomainVerification( + asset_id=ASSET_ID, + status=status, + verified_scope=VerificationScope.ZONE if verified else None, + verified_at=_T0 if verified else None, + challenge=None if verified else sample_challenge(checked=checked), + ) + + +def sample_reverification() -> DomainVerification: + """An exact-scoped proof holding while a challenge for zone coverage runs.""" + return DomainVerification( + asset_id=ASSET_ID, + status=VerificationStatus.VERIFIED, + verified_scope=VerificationScope.EXACT, + verified_at=_T0, + challenge=sample_challenge(), + ) + + +def sample_feed() -> AssetFeed: + """An active Atom feed for the asset.""" + return AssetFeed( + id=_FEED_ID, + asset_id=ASSET_ID, + format=FeedFormat.ATOM, + created_by_user_id=USER_ID, + last_used_at=_T1, + created_at=_T0, + ) + + +def sample_feed_created() -> AssetFeedCreated: + """The one response that carries the plaintext token.""" + return AssetFeedCreated( + **sample_feed().model_dump(), + token="fd_9xK2mQ7pL4vR8nT1", + feed_url="https://api.testing.nc3.lu/api/v1/feeds/fd_9xK2mQ7pL4vR8nT1", + ) diff --git a/src/nc3_testing_platform/domains/assets/router.py b/src/nc3_testing_platform/domains/assets/router.py new file mode 100644 index 0000000..24075d7 --- /dev/null +++ b/src/nc3_testing_platform/domains/assets/router.py @@ -0,0 +1,261 @@ +"""Asset inventory, domain-ownership verification, and syndication feeds. + +Every operation here is organization-scoped. Two routers ship from this module: +the authenticated asset router, and a separate unauthenticated one for public feed +delivery, which is authorized by a token in the path rather than by a caller. +""" + +from fastapi import APIRouter, Response, status + +from nc3_testing_platform.core.enums import VerificationStatus +from nc3_testing_platform.core.errors import problem_responses +from nc3_testing_platform.core.pagination import CursorPage, Page +from nc3_testing_platform.core.schemas import ResourceId +from nc3_testing_platform.core.security import Authenticated, MfaGated +from nc3_testing_platform.domains.assets import examples +from nc3_testing_platform.domains.assets.schemas import ( + Asset, + AssetCreate, + AssetFeed, + AssetFeedCreate, + AssetFeedCreated, + AssetUpdate, + DomainVerification, + VerificationCreate, +) +from nc3_testing_platform.domains.scans import examples as scan_examples +from nc3_testing_platform.domains.scans.schemas import ScanJob + +router = APIRouter( + prefix="/assets", + tags=["assets"], +) + +# Public feed delivery. Deliberately outside the authenticated router: the token in +# the path is the entire authorization, which is what makes a feed subscribable by +# a reader that cannot hold credentials. +public_feed_router = APIRouter(prefix="/feeds", tags=["assets"]) + + +@router.get( + "", + summary="List assets", + responses=problem_responses(401), + dependencies=[Authenticated], +) +async def list_assets(page: CursorPage) -> Page[Asset]: + """Assets owned by the caller's organization.""" + return Page( + items=[examples.sample_asset(), examples.sample_discovered_asset()], + next_cursor=None, + ) + + +@router.post( + "", + status_code=status.HTTP_201_CREATED, + summary="Register a domain", + responses=problem_responses(401, 409, 422), + dependencies=[Authenticated], +) +async def create_asset(body: AssetCreate) -> Asset: + """Register a domain to monitor. + + Conflicts with an existing asset for the same organization and value. + """ + return examples.sample_asset() + + +@router.get( + "/{asset_id}", + summary="Get an asset", + responses=problem_responses(401, 404), + dependencies=[Authenticated], +) +async def get_asset(asset_id: ResourceId) -> Asset: + """One asset. Verification state is a separate nested resource.""" + return examples.sample_asset() + + +@router.patch( + "/{asset_id}", + summary="Update an asset", + responses=problem_responses(401, 404, 422), + dependencies=[Authenticated], +) +async def update_asset(asset_id: ResourceId, body: AssetUpdate) -> Asset: + """Change regression alerting. Nothing else about an asset is mutable.""" + return examples.sample_asset() + + +@router.delete( + "/{asset_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete an asset", + responses=problem_responses(401, 403, 404, 409), + dependencies=[Authenticated], +) +async def delete_asset(asset_id: ResourceId) -> Response: + """Remove an asset from the inventory. + + Answers `409` while scan history, discovered children, a verification, a schedule, or a feed reference the asset. + """ + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get( + "/{asset_id}/scans", + summary="List scans for an asset", + responses=problem_responses(401, 404), + dependencies=[Authenticated], +) +async def list_asset_scans(asset_id: ResourceId, page: CursorPage) -> Page[ScanJob]: + """This asset's scan history, newest first.""" + return Page(items=[scan_examples.sample_job()], next_cursor=None) + + +@router.get( + "/{asset_id}/verification", + summary="Get verification state", + responses=problem_responses(401, 404), + dependencies=[Authenticated], +) +async def get_verification(asset_id: ResourceId) -> DomainVerification: + """Current ownership-verification state. `404` when none was ever started.""" + return examples.sample_verification() + + +@router.post( + "/{asset_id}/verification", + status_code=status.HTTP_201_CREATED, + summary="Start a verification challenge", + responses=problem_responses(401, 403, 404, 409, 422), + dependencies=[MfaGated], +) +async def create_verification( + asset_id: ResourceId, body: VerificationCreate +) -> DomainVerification: + """Issue a challenge at the requested coverage. + + Requires current MFA assurance, read from the OIDC token rather than from any + stored flag — proving control of a domain is what later authorizes scanning it. + + On an already-verified asset the response carries both the standing proof and + the new challenge, so coverage in force is never withdrawn while ownership is + re-proven. + """ + return examples.sample_reverification() + + +@router.post( + "/{asset_id}/verification/checks", + summary="Check the DNS record now", + responses=problem_responses(401, 404, 409), + dependencies=[Authenticated], +) +async def check_verification(asset_id: ResourceId) -> DomainVerification: + """Resolve the challenge record and update the state. + + Always sets `last_recheck_at`, so a user who presses this while DNS is still + propagating can see that it ran. Propagation takes anywhere from minutes to + two days, which makes "not found yet" the common outcome rather than an + exceptional one. + + A check that ran and found nothing is a result, not a fault: the response is + `200` with the state still `pending` and a `failure_code` saying why. + """ + return examples.sample_verification(checked=True) + + +@router.post( + "/{asset_id}/verification/token", + summary="Replace the verification token", + responses=problem_responses(401, 403, 404, 409), + dependencies=[Authenticated], +) +async def regenerate_verification_token(asset_id: ResourceId) -> DomainVerification: + """Issue a fresh token and expiry for a stalled challenge. + + Answers `409` when the asset is already verified. Regeneration exists for a + challenge that expired or whose token was lost, and a verified asset has + neither — replacing its token would discard a working proof and make the user + edit DNS again to get back where they started. + + To re-prove ownership, or to widen the scope, start a new challenge with + `POST .../verification`. The existing `verified_scope` holds until that + challenge succeeds, so nothing depending on the current proof breaks meanwhile. + """ + return examples.sample_verification(status=VerificationStatus.PENDING) + + +@router.get( + "/{asset_id}/feeds", + summary="List feeds for an asset", + responses=problem_responses(401, 404), + dependencies=[Authenticated], +) +async def list_asset_feeds(asset_id: ResourceId) -> list[AssetFeed]: + """Feeds configured for this asset, including revoked ones.""" + return [examples.sample_feed()] + + +@router.post( + "/{asset_id}/feeds", + status_code=status.HTTP_201_CREATED, + summary="Create a feed", + responses=problem_responses(401, 404, 422), + dependencies=[Authenticated], +) +async def create_asset_feed( + asset_id: ResourceId, body: AssetFeedCreate +) -> AssetFeedCreated: + """Create a syndication feed. + + The response is the only time the token and URL are readable; only the hash is + stored. A lost token is replaced by revoking and creating another. + """ + return examples.sample_feed_created() + + +@router.post( + "/{asset_id}/feeds/{feed_id}/revoke", + summary="Revoke a feed", + responses=problem_responses(401, 404, 409), + dependencies=[Authenticated], +) +async def revoke_asset_feed(asset_id: ResourceId, feed_id: ResourceId) -> AssetFeed: + """Stop serving a feed while keeping its row. + + A `POST` rather than a `DELETE`, because revocation is a recorded event and the + lifecycle survives it. + """ + feed = examples.sample_feed() + feed.revoked_at = feed.created_at + return feed + + +@public_feed_router.get( + "/{token}", + summary="Fetch a syndication feed", + response_class=Response, + responses={ + 200: { + "description": "RSS or Atom document, per the feed's configured format.", + "content": { + "application/rss+xml": {"schema": {"type": "string"}}, + "application/atom+xml": {"schema": {"type": "string"}}, + }, + }, + **problem_responses(404, 410), + }, +) +async def get_feed(token: str) -> Response: + """Serve a feed to a subscriber. + + Unauthenticated: the token is the authorization. A revoked feed answers `410`, + which tells an aggregator to stop polling rather than to retry. + """ + return Response( + content='\n\n', + media_type="application/atom+xml", + ) diff --git a/src/nc3_testing_platform/domains/assets/schemas.py b/src/nc3_testing_platform/domains/assets/schemas.py new file mode 100644 index 0000000..a1506a7 --- /dev/null +++ b/src/nc3_testing_platform/domains/assets/schemas.py @@ -0,0 +1,219 @@ +"""Assets, domain-ownership verification, and syndication feeds. + +An Asset is organization-owned, not user-owned: `created_by_user_id` is attribution +and nothing more, so a member leaving never orphans an asset or its history. + +Verification is a separate nested resource carrying the coverage proven and the +challenge running, not a field on the Asset. An asset list therefore carries no +verification badge; a client that wants one issues a second call per asset. +""" + +from pydantic import BaseModel, Field + +from nc3_testing_platform.core.enums import ( + AssetOrigin, + AssetType, + DnsRecordType, + FeedFormat, + VerificationScope, + VerificationStatus, +) +from nc3_testing_platform.core.schemas import ( + BaseSchema, + DomainName, + ResourceId, + Timestamp, +) + + +class Asset(BaseSchema): + """An organization-owned monitored domain.""" + + id: ResourceId + organization_id: ResourceId + asset_type: AssetType + value: DomainName = Field( + description="Canonical domain: lowercase IDNA (A-label) form without a trailing dot.", + examples=["example.lu"], + ) + origin: AssetOrigin = Field( + description="Whether a user registered this domain or discovery found it." + ) + parent_asset_id: ResourceId | None = Field( + default=None, + description="The asset whose subdomain discovery produced this one.", + ) + created_by_user_id: ResourceId | None = Field( + default=None, + description="Attribution only. Null once the creating user is erased.", + ) + regression_alerts_enabled: bool = Field( + default=False, + description="Notify the organization when a resolved finding reappears.", + ) + created_at: Timestamp + updated_at: Timestamp + + +class AssetCreate(BaseModel): + """Register a domain to monitor.""" + + value: DomainName = Field( + description=( + "Domain to monitor. Unicode or ASCII input is accepted and " + "canonicalized to lowercase IDNA (A-label) form without a trailing dot." + ), + examples=["example.lu"], + ) + asset_type: AssetType = AssetType.DOMAIN + + +class AssetUpdate(BaseModel): + """Change the one mutable property of an asset. + + `value` and `asset_type` are immutable: a different domain is a different asset, + with its own scan history and its own ownership proof. Retargeting one in place + would silently reattribute both. + """ + + regression_alerts_enabled: bool + + +class VerificationChallenge(BaseSchema): + """A DNS record to publish to prove control of the domain. + + Present while a challenge is answerable, and while its last check is being shown. + Absent once the challenge has produced its proof and absent on a domain with no challenge running. + """ + + id: ResourceId + requested_scope: VerificationScope = Field( + description="Coverage this challenge proves if it succeeds." + ) + record_type: DnsRecordType = Field( + default=DnsRecordType.TXT, + description="Type of DNS record to create.", + ) + record_name: str = Field( + description=( + "Name of the DNS record to create. Computed by the server from the " + "asset's domain and a deployment-configured vendor prefix. Display the " + "returned value; do not rebuild it." + ), + examples=["_nc3-verify.example.lu"], + ) + verification_token: str = Field( + description=( + "Complete value of the record, pasted verbatim. The client does not " + "wrap, prefix, or encode it." + ), + examples=["verify-4f7a2c9e1b8d3056"], + ) + token_expires_at: Timestamp = Field( + description=( + "When the challenge stops being answerable. Seven days from issue by " + "default. Reaching it retires the challenge; a proof already recorded is " + "unaffected." + ) + ) + requested_by_user_id: ResourceId | None = None + requested_at: Timestamp + last_recheck_at: Timestamp | None = Field( + default=None, + description=( + "When the record was last looked for, whatever triggered the lookup — a " + "user pressing verify, or the recheck that runs before an intrusive " + "task is queued. Null until the first check. Pair it with " + "`failure_code` to show why the last attempt did not succeed." + ), + ) + failure_code: str | None = Field( + default=None, + description="Stable namespaced reason the last check did not succeed.", + ) + + +class DomainVerification(BaseSchema): + """Current ownership state of one domain: the proof, plus any challenge running against it. + + The proof and the challenge are independent. + A domain that is already verified keeps `verified_scope` while a new challenge runs, so re-proving ownership or widening coverage never withdraws coverage that is already proven. + + Only the current state lives here. + Attempts and transitions are recorded in the audit log. + """ + + asset_id: ResourceId + status: VerificationStatus = Field( + description=( + "`verified` whenever a proof exists, whatever the challenge is doing. " + "`pending` while an unanswered challenge is still answerable. `expired` " + "once it is no longer answerable and no proof exists." + ) + ) + verified_scope: VerificationScope | None = Field( + default=None, + description=( + "Coverage actually proven, null until a challenge first succeeds. " + "Requesting a wider scope does not widen this until that challenge " + "succeeds, so an existing proof is never silently upgraded." + ), + ) + verified_at: Timestamp | None = Field( + default=None, + description="When the proof was recorded. Non-null exactly when `verified_scope` is.", + ) + challenge: VerificationChallenge | None = Field( + default=None, + description=( + "The challenge currently running, or null when none is. It appears " + "alongside `verified_scope` while an already-verified domain re-proves " + "ownership or widens its coverage." + ), + ) + + +class VerificationCreate(BaseModel): + """Start a domain-ownership challenge at a chosen coverage.""" + + requested_scope: VerificationScope = Field( + description=( + "`exact` covers this domain alone. `zone` covers it and everything " + "beneath it, evaluated by DNS-label ancestry rather than string suffix, " + "so `evil-example.lu` is never treated as part of `example.lu`." + ) + ) + + +class AssetFeed(BaseSchema): + """A per-asset syndication feed with a revocable token.""" + + id: ResourceId + asset_id: ResourceId + format: FeedFormat + created_by_user_id: ResourceId | None = None + revoked_at: Timestamp | None = Field( + default=None, description="Set on revocation. The row is kept." + ) + last_used_at: Timestamp | None = None + created_at: Timestamp + + +class AssetFeedCreate(BaseModel): + """Create a feed for an asset.""" + + format: FeedFormat + + +class AssetFeedCreated(AssetFeed): + """Creation response. The only time the token is ever readable. + + Only the hash is stored, so a lost token cannot be recovered — revoke the feed + and create another. + """ + + token: str = Field(description="Plaintext feed token. Shown once.") + feed_url: str = Field( + description="Fully-qualified URL to subscribe to. Shown once.", + examples=["https://api.testing.nc3.lu/api/v1/feeds/fd_9xK2mQ7pL4vR8nT1"], + ) diff --git a/src/nc3_testing_platform/domains/findings/__init__.py b/src/nc3_testing_platform/domains/findings/__init__.py new file mode 100644 index 0000000..49e37af --- /dev/null +++ b/src/nc3_testing_platform/domains/findings/__init__.py @@ -0,0 +1 @@ +"""Organization-wide finding reads.""" diff --git a/src/nc3_testing_platform/domains/findings/router.py b/src/nc3_testing_platform/domains/findings/router.py new file mode 100644 index 0000000..7c3fc95 --- /dev/null +++ b/src/nc3_testing_platform/domains/findings/router.py @@ -0,0 +1,65 @@ +"""Organization-wide finding reads. + +Read-only. `new`, `regression`, `persistent`, and `resolved` are derived by +comparing a result against the asset's history, so there is no operation to set +one: a manual override would corrupt the next comparison. + +The `Finding` model itself lives in the scans domain, because a finding belongs to +a scan result. This domain only adds the cross-asset views. +""" + +from typing import Annotated + +from fastapi import APIRouter, Query + +from nc3_testing_platform.core.enums import FindingSeverity, FindingStatus +from nc3_testing_platform.core.errors import problem_responses +from nc3_testing_platform.core.pagination import CursorPage, Page +from nc3_testing_platform.core.schemas import ResourceId +from nc3_testing_platform.core.security import Authenticated +from nc3_testing_platform.domains.scans import examples +from nc3_testing_platform.domains.scans.schemas import Finding + +router = APIRouter( + prefix="/findings", + tags=["findings"], +) + + +@router.get( + "", + summary="List findings", + responses=problem_responses(401), + dependencies=[Authenticated], +) +async def list_findings( + page: CursorPage, + severity: Annotated[ + FindingSeverity | None, Query(description="Filter by severity band.") + ] = None, + status: Annotated[ + FindingStatus | None, + Query(description="Filter by historical-comparison classification."), + ] = None, + asset_id: Annotated[ + ResourceId | None, + Query(description="Restrict to findings raised against one asset."), + ] = None, + scan_job_id: Annotated[ + ResourceId | None, + Query(description="Restrict to findings from one scan."), + ] = None, +) -> Page[Finding]: + """Findings across the caller's organization.""" + return Page(items=examples.sample_findings(), next_cursor=None) + + +@router.get( + "/{finding_id}", + summary="Get a finding", + responses=problem_responses(401, 404), + dependencies=[Authenticated], +) +async def get_finding(finding_id: ResourceId) -> Finding: + """One finding in full, including its evidence.""" + return examples.sample_findings()[0] diff --git a/src/nc3_testing_platform/domains/health/__init__.py b/src/nc3_testing_platform/domains/health/__init__.py new file mode 100644 index 0000000..9244e28 --- /dev/null +++ b/src/nc3_testing_platform/domains/health/__init__.py @@ -0,0 +1 @@ +"""Liveness and readiness probes.""" diff --git a/src/nc3_testing_platform/domains/health/router.py b/src/nc3_testing_platform/domains/health/router.py new file mode 100644 index 0000000..b1a200e --- /dev/null +++ b/src/nc3_testing_platform/domains/health/router.py @@ -0,0 +1,29 @@ +"""Liveness and readiness probes. + +Mounted at the root rather than under `/api/v1`: an orchestrator probing a pod +should not have to know the API version, and these must keep answering across a +version bump. +""" + +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter(tags=["health"]) + + +class HealthStatus(BaseModel): + """Probe result.""" + + status: str + + +@router.get("/healthz", summary="Liveness probe") +async def healthz() -> HealthStatus: + """The process is up. Says nothing about dependencies.""" + return HealthStatus(status="ok") + + +@router.get("/readyz", summary="Readiness probe") +async def readyz() -> HealthStatus: + """The process is ready to serve traffic.""" + return HealthStatus(status="ready") diff --git a/src/nc3_testing_platform/domains/notifications/__init__.py b/src/nc3_testing_platform/domains/notifications/__init__.py new file mode 100644 index 0000000..7767580 --- /dev/null +++ b/src/nc3_testing_platform/domains/notifications/__init__.py @@ -0,0 +1 @@ +"""Inbox, account preference, and organization webhook operations.""" diff --git a/src/nc3_testing_platform/domains/notifications/router.py b/src/nc3_testing_platform/domains/notifications/router.py new file mode 100644 index 0000000..58dfe71 --- /dev/null +++ b/src/nc3_testing_platform/domains/notifications/router.py @@ -0,0 +1,181 @@ +"""Inbox, account preference, and organization webhook operations.""" + +from datetime import UTC, datetime +from uuid import UUID + +from fastapi import APIRouter, Response, status +from pydantic import AnyHttpUrl + +from nc3_testing_platform.core.enums import OrganizationRole +from nc3_testing_platform.core.errors import problem_responses +from nc3_testing_platform.core.pagination import CursorPage, Page +from nc3_testing_platform.core.schemas import ResourceId +from nc3_testing_platform.core.security import Authenticated +from nc3_testing_platform.domains.notifications.schemas import ( + Account, + AccountUpdate, + Notification, + OrganizationWebhook, + OrganizationWebhookUpsert, +) +from nc3_testing_platform.domains.scans.examples import JOB_ID, ORGANIZATION_ID, USER_ID + +router = APIRouter(prefix="/notifications", tags=["notifications"]) + +# `PATCH /account` is grouped with notifications because the only preference it +# carries is the email opt-in; `GET /account` reads the same projection. +account_router = APIRouter(prefix="/account", tags=["account"]) + +_NOTIFICATION_ID = UUID("019ee1a6-0011-7a22-8b33-4c44d5e66f77") +_WEBHOOK_ID = UUID("019ee1a6-1122-7b33-9c44-5d55e6f77a88") +_T = datetime(2026, 7, 31, 9, 2, tzinfo=UTC) + + +def _sample_notification() -> Notification: + return Notification( + id=_NOTIFICATION_ID, + type="scan.completed", + schema_version="1.0", + data={"scan_job_id": str(JOB_ID), "status": "partial"}, + created_at=_T, + ) + + +@router.get( + "", + summary="List notifications", + responses=problem_responses(401), + dependencies=[Authenticated], +) +async def list_notifications(page: CursorPage) -> Page[Notification]: + """The caller's inbox, newest first.""" + return Page(items=[_sample_notification()], next_cursor=None) + + +@router.post( + "/read-all", + status_code=status.HTTP_204_NO_CONTENT, + summary="Mark all as read", + responses=problem_responses(401), + dependencies=[Authenticated], +) +async def mark_all_read() -> Response: + """Mark every unread notification belonging to the caller. + + Returns no body: the caller already knows the outcome, and re-sending the + inbox here would duplicate `GET /notifications`. + """ + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.post( + "/{notification_id}/read", + summary="Mark one as read", + responses=problem_responses(401, 404), + dependencies=[Authenticated], +) +async def mark_read(notification_id: ResourceId) -> Notification: + """Set `read_at` on one of the caller's notifications.""" + notification = _sample_notification() + notification.read_at = _T + return notification + + +@router.get( + "/webhook", + summary="Get the organization webhook", + responses=problem_responses(401, 403, 404), + dependencies=[Authenticated], +) +async def get_webhook() -> OrganizationWebhook: + """The organization's SIEM endpoint. `404` when none is configured.""" + return _sample_webhook() + + +@router.put( + "/webhook", + summary="Create or replace the organization webhook", + responses=problem_responses(401, 403, 422), + dependencies=[Authenticated], +) +async def upsert_webhook(body: OrganizationWebhookUpsert) -> OrganizationWebhook: + """Set the single webhook configuration for the organization.""" + return _sample_webhook() + + +@router.delete( + "/webhook", + status_code=status.HTTP_204_NO_CONTENT, + summary="Disable the organization webhook", + responses=problem_responses(401, 403, 404), + dependencies=[Authenticated], +) +async def delete_webhook() -> Response: + """Delete the configuration, which disables the integration. + + A `DELETE` rather than a revoke: nothing about the configuration needs to + stay visible after it is switched off. + """ + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +def _sample_webhook() -> OrganizationWebhook: + return OrganizationWebhook( + id=_WEBHOOK_ID, + organization_id=ORGANIZATION_ID, + endpoint_url=AnyHttpUrl("https://siem.example.lu/ingest/nc3"), + created_by_user_id=USER_ID, + created_at=_T, + updated_at=_T, + ) + + +@router.delete( + "/{notification_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Dismiss a notification", + responses=problem_responses(401, 404), + dependencies=[Authenticated], +) +async def dismiss_notification(notification_id: ResourceId) -> Response: + """Permanently remove the caller's row. + + Dismissal is deletion in v4.0 — there is no archived state, and no other user + is affected because the row was never shared. + """ + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@account_router.get( + "", + summary="Get the current account", + responses=problem_responses(401), + dependencies=[Authenticated], +) +async def get_account() -> Account: + """The caller's `app_user` projection: identity, organization, role, preference.""" + return Account( + id=USER_ID, + email="analyst@example.lu", + display_name="A. Analyst", + organization_id=ORGANIZATION_ID, + organization_role=OrganizationRole.ORGANIZATION_ADMIN, + email_notifications_enabled=True, + ) + + +@account_router.patch( + "", + summary="Update account preferences", + responses=problem_responses(401, 422), + dependencies=[Authenticated], +) +async def update_account(body: AccountUpdate) -> Account: + """Change the email-notification opt-in. + + Profile fields are not editable here. They live in the identity provider and reach this + projection through claim updates. + """ + account = await get_account() + account.email_notifications_enabled = body.email_notifications_enabled + return account diff --git a/src/nc3_testing_platform/domains/notifications/schemas.py b/src/nc3_testing_platform/domains/notifications/schemas.py new file mode 100644 index 0000000..51f1768 --- /dev/null +++ b/src/nc3_testing_platform/domains/notifications/schemas.py @@ -0,0 +1,102 @@ +"""User inbox, account preferences, and the organization webhook. + +Notifications are deliberately minimal: one row per user per event, owned by that +user alone. No organization scope, no shared read state, no parent event resource, +no per-type preferences. Two people in the same organization each get their own +row, and one reading it does not mark it read for the other. + +In-app delivery has no opt-out. Email delivery has exactly one switch, on the +account, which is why there is no notification-settings resource to configure. +""" + +from typing import Any + +from pydantic import AnyHttpUrl, BaseModel, EmailStr, Field + +from nc3_testing_platform.core.enums import OrganizationRole +from nc3_testing_platform.core.schemas import BaseSchema, ResourceId, Timestamp + + +class Notification(BaseSchema): + """One inbox item belonging to one user.""" + + id: ResourceId + type: str = Field( + description=( + "Namespaced notification type. v4.0 covers verification completion, " + "regressions, scan completion and failure, retention warnings, and " + "token expiry. The vocabulary is code-owned and extends freely." + ), + examples=["scan.completed"], + ) + schema_version: str = Field( + description="Version of this type's `data` shape.", + examples=["1.0"], + ) + # `data` has no fixed schema; contents are per-type and code-owned. + data: dict[str, Any] = Field( + default_factory=dict, description="Type-specific payload." + ) + read_at: Timestamp | None = Field( + default=None, description="Null while unread. There is no separate flag." + ) + created_at: Timestamp + + +class Account(BaseSchema): + """The caller's `app_user` projection. + + Read-only. Identity, credentials, display name, and email are owned by the identity provider + and edited there; they arrive here through claim updates. The one field this + platform owns is the email-notification preference. + """ + + id: ResourceId + email: EmailStr + display_name: str | None = None + organization_id: ResourceId + organization_role: OrganizationRole = Field( + description=( + "Role within the organization. Platform-administrator status is a " + "separate identity-provider claim and never appears here." + ) + ) + email_notifications_enabled: bool + + +class AccountUpdate(BaseModel): + """The only account field this API can change.""" + + email_notifications_enabled: bool + + +class OrganizationWebhook(BaseSchema): + """The organization's single SIEM integration endpoint. + + The signing secret is never returned. It is set once on write and thereafter + only ever used to sign outgoing payloads. + """ + + id: ResourceId + organization_id: ResourceId + endpoint_url: AnyHttpUrl + created_by_user_id: ResourceId | None = None + created_at: Timestamp + updated_at: Timestamp + + +class OrganizationWebhookUpsert(BaseModel): + """Create or replace the webhook configuration. + + A `PUT` rather than a `POST`, because an organization has zero or one of these + """ + + endpoint_url: AnyHttpUrl + signing_secret: str = Field( + min_length=32, + description=( + "Shared secret used to sign delivered payloads. Stored encrypted and " + "never returned. The payload's own `schema_version` belongs to the " + "signed contract, not to this configuration." + ), + ) diff --git a/src/nc3_testing_platform/domains/org/__init__.py b/src/nc3_testing_platform/domains/org/__init__.py new file mode 100644 index 0000000..33eceac --- /dev/null +++ b/src/nc3_testing_platform/domains/org/__init__.py @@ -0,0 +1 @@ +"""Organization members and invitations.""" diff --git a/src/nc3_testing_platform/domains/org/router.py b/src/nc3_testing_platform/domains/org/router.py new file mode 100644 index 0000000..73eb75d --- /dev/null +++ b/src/nc3_testing_platform/domains/org/router.py @@ -0,0 +1,190 @@ +"""Organization members and invitations. + +Two routers: administrative operations under `/org`, and the invitee-facing pair +under `/invitations/{token}`, which is reached by someone who may not be a member +of anything yet. +""" + +from datetime import UTC, datetime +from uuid import UUID + +from fastapi import APIRouter, status + +from nc3_testing_platform.core.enums import OrganizationRole +from nc3_testing_platform.core.errors import problem_responses +from nc3_testing_platform.core.pagination import CursorPage, Page +from nc3_testing_platform.core.schemas import ResourceId +from nc3_testing_platform.core.security import Authenticated +from nc3_testing_platform.domains.org.schemas import ( + Invitation, + InvitationCreate, + InvitationPreview, + Member, + MemberRoleUpdate, +) +from nc3_testing_platform.domains.scans.examples import ORGANIZATION_ID, USER_ID + +router = APIRouter(prefix="/org", tags=["organization"]) + +# Invitee-facing. The preview is readable by anyone holding the link; acceptance +# still requires an authenticated caller whose verified email matches. +invitation_router = APIRouter(prefix="/invitations", tags=["organization"]) + +_INVITATION_ID = UUID("019ee1a7-0011-7a22-8b33-4c44d5e66f77") +_T = datetime(2026, 7, 28, 10, 0, tzinfo=UTC) +_EXPIRES = datetime(2026, 8, 11, 10, 0, tzinfo=UTC) + + +def _sample_member(disabled: bool = False) -> Member: + return Member( + user_id=USER_ID, + email="analyst@example.lu", + display_name="A. Analyst", + organization_role=OrganizationRole.ORGANIZATION_ADMIN, + disabled_at=_T if disabled else None, + created_at=_T, + ) + + +def _sample_invitation() -> Invitation: + return Invitation( + id=_INVITATION_ID, + organization_id=ORGANIZATION_ID, + email="newcomer@example.lu", + organization_role=OrganizationRole.MEMBER, + invited_by_user_id=USER_ID, + expires_at=_EXPIRES, + created_at=_T, + ) + + +@router.get( + "/members", + summary="List members", + responses=problem_responses(401, 403), + dependencies=[Authenticated], +) +async def list_members(page: CursorPage) -> Page[Member]: + """Members of the caller's organization.""" + return Page(items=[_sample_member()], next_cursor=None) + + +@router.patch( + "/members/{user_id}", + summary="Change a member's role", + responses=problem_responses(401, 403, 404, 409, 422), + dependencies=[Authenticated], +) +async def update_member_role(user_id: ResourceId, body: MemberRoleUpdate) -> Member: + """Promote or demote a member. + + Answers `409` when the change would leave no enabled administrator. + """ + return _sample_member() + + +@router.post( + "/members/{user_id}/disable", + summary="Disable a member", + responses=problem_responses(401, 403, 404, 409), + dependencies=[Authenticated], +) +async def disable_member(user_id: ResourceId) -> Member: + """Revoke a member's access without removing them or their attribution. + + There is no removal operation: disabling ends access, and erasure — a separate + workflow — removes the person. + """ + return _sample_member(disabled=True) + + +@router.post( + "/members/{user_id}/enable", + summary="Re-enable a member", + responses=problem_responses(401, 403, 404), + dependencies=[Authenticated], +) +async def enable_member(user_id: ResourceId) -> Member: + """Restore access to a disabled member.""" + return _sample_member() + + +@router.get( + "/invitations", + summary="List invitations", + responses=problem_responses(401, 403), + dependencies=[Authenticated], +) +async def list_invitations(page: CursorPage) -> Page[Invitation]: + """Invitations issued by this organization, including spent and revoked ones.""" + return Page(items=[_sample_invitation()], next_cursor=None) + + +@router.post( + "/invitations", + status_code=status.HTTP_201_CREATED, + summary="Invite someone to the organization", + responses=problem_responses(401, 403, 409, 422), + dependencies=[Authenticated], +) +async def create_invitation(body: InvitationCreate) -> Invitation: + """Issue an invitation and email the link. + + Answers `409` when a live invitation already exists for the same address. There + is no resend: revoke the outstanding one and issue another, so every link ever + sent has its own auditable row. + """ + return _sample_invitation() + + +@router.delete( + "/invitations/{invitation_id}", + summary="Revoke an invitation", + responses=problem_responses(401, 403, 404, 409), + dependencies=[Authenticated], +) +async def revoke_invitation(invitation_id: ResourceId) -> Invitation: + """Invalidate the outstanding link. + + The row survives with `revoked_at` set, which is why this returns the invitation + rather than `204` — revocation changes state, it does not erase it. + """ + invitation = _sample_invitation() + invitation.revoked_at = _T + return invitation + + +@invitation_router.get( + "/{token}", + summary="Preview an invitation", + responses=problem_responses(404, 410), +) +async def preview_invitation(token: str) -> InvitationPreview: + """What the invitee sees before deciding. + + Unauthenticated, because the recipient may have no account yet. Answers `410` + for a spent, revoked, or expired token so the UI can say which. + """ + return InvitationPreview( + organization_name="Example Luxembourg S.A.", + organization_role=OrganizationRole.MEMBER, + email="newcomer@example.lu", + expires_at=_EXPIRES, + ) + + +@invitation_router.post( + "/{token}/acceptance", + status_code=status.HTTP_201_CREATED, + summary="Accept an invitation", + responses=problem_responses(401, 403, 404, 409, 410), + dependencies=[Authenticated], +) +async def accept_invitation(token: str) -> Member: + """Join the organization. + + Atomic, and gated on three things at once: the token is live, the caller's + verified email matches the invited address, and the caller does not already + belong to another organization. Failing any of them changes nothing. + """ + return _sample_member() diff --git a/src/nc3_testing_platform/domains/org/schemas.py b/src/nc3_testing_platform/domains/org/schemas.py new file mode 100644 index 0000000..b902af9 --- /dev/null +++ b/src/nc3_testing_platform/domains/org/schemas.py @@ -0,0 +1,85 @@ +"""Organization membership and invitations. + +Membership is created by exactly one route: accepting an invitation. There is no +operation that adds a member directly, so the email-match check on acceptance +cannot be bypassed. + +Only the invitation token's hash is stored. The plaintext exists in the emailed +link and nowhere else, so a database read cannot yield a usable invitation. + +A registered user belongs to exactly one organization, so leaving is not modeled. +A member who should lose access is disabled; a member who should be forgotten is +erased, which is a separate workflow with its own thirty-day guarantee. +""" + +from pydantic import BaseModel, EmailStr, Field + +from nc3_testing_platform.core.enums import OrganizationRole +from nc3_testing_platform.core.schemas import BaseSchema, ResourceId, Timestamp + + +class Member(BaseSchema): + """One user's membership of the organization.""" + + user_id: ResourceId + email: EmailStr + display_name: str | None = None + organization_role: OrganizationRole + disabled_at: Timestamp | None = Field( + default=None, + description="Set while the member is disabled. A disabled user cannot authenticate.", + ) + created_at: Timestamp + + +class MemberRoleUpdate(BaseModel): + """Change a member's role. + + Rejected when it would remove the last enabled administrator — an organization + that cannot administer itself has no route back without operator intervention. + """ + + organization_role: OrganizationRole + + +class InvitationCreate(BaseModel): + """Invite one email address to join at one role.""" + + email: EmailStr + organization_role: OrganizationRole = OrganizationRole.MEMBER + + +class Invitation(BaseSchema): + """An invitation's lifecycle, as the inviting organization sees it. + + The token never appears here in any form. + """ + + id: ResourceId + organization_id: ResourceId + email: EmailStr + organization_role: OrganizationRole + invited_by_user_id: ResourceId | None = None + expires_at: Timestamp + accepted_by_user_id: ResourceId | None = None + accepted_at: Timestamp | None = None + revoked_at: Timestamp | None = Field( + default=None, + description="Set on revocation. The row is kept, so the attempt stays visible.", + ) + created_at: Timestamp + + +class InvitationPreview(BaseSchema): + """What an invitee can see before accepting. + + Deliberately thin. Anyone holding the link can read this, so it carries the + organization's name and nothing that would leak its membership or activity. + """ + + organization_name: str + organization_role: OrganizationRole + email: EmailStr = Field( + description="The invited address. Acceptance requires a verified match." + ) + expires_at: Timestamp diff --git a/src/nc3_testing_platform/domains/reports/__init__.py b/src/nc3_testing_platform/domains/reports/__init__.py new file mode 100644 index 0000000..1ba5fca --- /dev/null +++ b/src/nc3_testing_platform/domains/reports/__init__.py @@ -0,0 +1 @@ +"""Report generation and provenance listing.""" diff --git a/src/nc3_testing_platform/domains/reports/router.py b/src/nc3_testing_platform/domains/reports/router.py new file mode 100644 index 0000000..4bbd9d5 --- /dev/null +++ b/src/nc3_testing_platform/domains/reports/router.py @@ -0,0 +1,118 @@ +"""Report generation and provenance listing.""" + +from datetime import UTC, datetime +from uuid import UUID + +from fastapi import APIRouter, Response, status + +from nc3_testing_platform.core.enums import ( + ReportFormat, + ReportLanguage, + ReportTier, + TechnicalReportView, +) +from nc3_testing_platform.core.errors import problem_responses +from nc3_testing_platform.core.pagination import CursorPage, Page +from nc3_testing_platform.core.security import Authenticated +from nc3_testing_platform.domains.reports.schemas import Report, ReportRequest +from nc3_testing_platform.domains.scans.examples import JOB_ID, ORGANIZATION_ID, USER_ID + +router = APIRouter( + prefix="/reports", + tags=["reports"], +) + +_REPORT_ID = UUID("019ee1a5-0011-7a22-8b33-4c44d5e66f77") + +# The rendered document comes back in the response body, so the operation answers +# with whichever media type the requested `format` names. +_ARTIFACT_MEDIA_TYPES = { + ReportFormat.PDF: "application/pdf", + ReportFormat.DOCX: ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ), + ReportFormat.JSON: "application/json", +} + +# Smallest body that is recognizable as the declared media type. A client sniffing +# the response, or a mock consumer asserting on it, must not be handed PDF bytes +# labelled as DOCX. +# +# The report's actual content is deferred to v4.1: it is assembled from scan +# results, whose shapes belong to the scan modules and do not exist yet +# (`[RFD 1, gap 2]`). Only the format-to-media-type mapping is in v4.0 scope. +_ARTIFACT_STUBS = { + ReportFormat.PDF: b"%PDF-1.7\n%%EOF\n", + # DOCX is an OPC package, so a DOCX body begins with the ZIP local-file header. + ReportFormat.DOCX: b"PK\x03\x04", + ReportFormat.JSON: b'{"report": "Content is deferred to v4.1."}\n', +} + + +def _sample_report() -> Report: + return Report( + id=_REPORT_ID, + organization_id=ORGANIZATION_ID, + tier=ReportTier.TECHNICAL, + technical_view=TechnicalReportView.FULL, + format=ReportFormat.PDF, + language=ReportLanguage.EN, + source_scan_job_id=JOB_ID, + generated_by_user_id=USER_ID, + generated_at=datetime(2026, 7, 31, 9, 5, tzinfo=UTC), + ) + + +@router.post( + "", + status_code=status.HTTP_201_CREATED, + summary="Generate a report", + response_class=Response, + responses={ + 201: { + "description": ( + "The rendered document, in the requested format." + "To obtain it again, submit another request while the source scan " + "is still retained." + ), + "content": { + media_type: {"schema": {"type": "string", "format": "binary"}} + if media_type != "application/json" + else {"schema": {"type": "object"}} + for media_type in _ARTIFACT_MEDIA_TYPES.values() + }, + }, + **problem_responses(401, 404, 409, 422), + }, + dependencies=[Authenticated], +) +async def generate_report(body: ReportRequest) -> Response: + """Render a report synchronously from one retained scan source. + + Answers `409` once the source has been purged: the provenance row may still + exist, but the data it was drawn from no longer does. + + The body is a placeholder of the right type, not a real report. Report content + is assembled from scan results, and those shapes are owned by the scan modules, + which do not exist yet. + """ + return Response( + content=_ARTIFACT_STUBS[body.format], + media_type=_ARTIFACT_MEDIA_TYPES[body.format], + status_code=status.HTTP_201_CREATED, + ) + + +@router.get( + "", + summary="List generated reports", + responses=problem_responses(401), + dependencies=[Authenticated], +) +async def list_reports(page: CursorPage) -> Page[Report]: + """Provenance metadata for reports this organization has generated. + + Metadata only. No entry here can be turned back into a document; that requires + generating a new one from a source that is still retained. + """ + return Page(items=[_sample_report()], next_cursor=None) diff --git a/src/nc3_testing_platform/domains/reports/schemas.py b/src/nc3_testing_platform/domains/reports/schemas.py new file mode 100644 index 0000000..c1400f8 --- /dev/null +++ b/src/nc3_testing_platform/domains/reports/schemas.py @@ -0,0 +1,73 @@ +"""Report generation from a retained scan. + +Only metadata persists. `POST /reports` renders synchronously and hands +back the document; the metadata records who generated what, from which +source, in which language. + +The way to get the document again is to ask for it again, and that only +works while the source scan is still retained. Once `purge_at` passes, the +metadata row survives as evidence that a report once existed, and no further +copy can be produced from it. +""" + +from pydantic import BaseModel, Field, model_validator + +from nc3_testing_platform.core.enums import ( + ReportFormat, + ReportLanguage, + ReportTier, + TechnicalReportView, +) +from nc3_testing_platform.core.schemas import BaseSchema, ResourceId, Timestamp + + +class ReportRequest(BaseModel): + """Generate one report from exactly one source.""" + + tier: ReportTier + technical_view: TechnicalReportView | None = Field( + default=None, + description="Depth of a technical report. Meaningless for the executive tier.", + ) + format: ReportFormat + language: ReportLanguage = ReportLanguage.EN + source_scan_job_id: ResourceId | None = Field( + default=None, description="Report on a whole scan. Mutually exclusive." + ) + source_scan_task_id: ResourceId | None = Field( + default=None, description="Report on one test's result. Mutually exclusive." + ) + + @model_validator(mode="after") + def _exactly_one_source(self) -> "ReportRequest": + if bool(self.source_scan_job_id) == bool(self.source_scan_task_id): + raise ValueError( + "Supply exactly one of `source_scan_job_id` or `source_scan_task_id`." + ) + return self + + @model_validator(mode="after") + def _technical_view_requires_technical_tier(self) -> "ReportRequest": + if self.technical_view is not None and self.tier is not ReportTier.TECHNICAL: + raise ValueError("`technical_view` applies only to the technical tier.") + return self + + +class Report(BaseSchema): + """Provenance metadata for one generated report. + + The source identifiers deliberately carry no foreign key. + """ + + id: ResourceId + organization_id: ResourceId + tier: ReportTier + technical_view: TechnicalReportView | None = None + format: ReportFormat + language: ReportLanguage + source_scan_job_id: ResourceId | None = None + source_scan_task_id: ResourceId | None = None + generated_by_user_id: ResourceId | None = Field( + default=None, description="Attribution only. Null once the user is erased." + ) + generated_at: Timestamp diff --git a/src/nc3_testing_platform/domains/scans/__init__.py b/src/nc3_testing_platform/domains/scans/__init__.py new file mode 100644 index 0000000..55e756b --- /dev/null +++ b/src/nc3_testing_platform/domains/scans/__init__.py @@ -0,0 +1 @@ +"""Scan launch, lifecycle, live progress, retention, and deletion.""" diff --git a/src/nc3_testing_platform/domains/scans/dependencies.py b/src/nc3_testing_platform/domains/scans/dependencies.py new file mode 100644 index 0000000..60dedb6 --- /dev/null +++ b/src/nc3_testing_platform/domains/scans/dependencies.py @@ -0,0 +1,155 @@ +"""Body validation for the launch operation. + +`POST /scans` accepts three request schemas on one path, selected by media type +and — inside JSON — by the caller's access state. FastAPI derives one body model +per operation from type hints, so it cannot make that choice; this dependency does +it and hands the handler an already-validated object. + +Failures raise `RequestValidationError`, FastAPI's own validation exception, so +they travel through the handler registered in `app.core.errors` and come out as +`application/problem+json` with the same field-level `errors` array as every other +endpoint. Raising `HTTPException` here would produce a second, differently-shaped +validation error for one operation. +""" + +from typing import Annotated, Any + +from fastapi import Depends, HTTPException, Query, Request, status +from fastapi.exceptions import RequestValidationError +from pydantic import BaseModel, ValidationError + +from nc3_testing_platform.core.security import ApiKeyAuth, OidcAuth +from nc3_testing_platform.domains.scans.schemas import ( + AssetScanLaunch, + FileScanLaunch, + GuestScanLaunch, +) + +JSON_MEDIA_TYPE = "application/json" +MULTIPART_MEDIA_TYPE = "multipart/form-data" + +# Read access to a guest scan, for a caller who has no account yet. +# +# A query parameter rather than a header because one of the three operations it +# covers is an SSE stream, and `EventSource` cannot set headers. Using the same +# transport on all three keeps one rule for the client. +# +# Reading does not spend the token. Only the claim does, because after the scan is +# attributed to an organization the account is the credential and the token has +# nothing left to authorize. +ScanAccessToken = Annotated[ + str | None, + Query( + alias="claim_token", + description=( + "One-time token returned by an unauthenticated launch. Required to read " + "a guest scan that has not been claimed; ignored when the caller is " + "authenticated and owns the scan. Reading does not consume it." + ), + ), +] + +# `FileScanLaunch` is documentation-only — its `file` field is the OpenAPI 3.1 +# encoding of a binary part, not a runtime string — so a multipart launch is +# validated against the form rather than through the model. +ScanLaunch = AssetScanLaunch | GuestScanLaunch | FileScanLaunch + + +class ResolvedLaunch(BaseModel): + """A validated launch request plus the access state that selected it. + + Both are needed downstream and neither implies the other: a file launch can be + anonymous, in which case it is a guest job that carries a claim token, so + `isinstance(body, FileScanLaunch)` alone cannot tell an owned scan from a + claimable one. + """ + + body: ScanLaunch + authenticated: bool + + +def _invalid(loc: tuple[str, ...], message: str) -> RequestValidationError: + """One validation error in the shape FastAPI's handler expects.""" + return RequestValidationError( + [{"type": "value_error", "loc": loc, "msg": message, "input": None}] + ) + + +def _from_pydantic(exc: ValidationError) -> RequestValidationError: + """Re-raise a model validation failure as a request validation failure. + + Pydantic reports locations relative to the model; the contract reports them + relative to the request, so each one is prefixed with `body`. + """ + return RequestValidationError( + [{**error, "loc": ("body", *error["loc"])} for error in exc.errors()] + ) + + +def _validate(model: type[BaseModel], payload: dict[str, Any]) -> Any: + try: + return model.model_validate(payload) + except ValidationError as exc: + raise _from_pydantic(exc) from exc + + +async def resolve_launch_body( + request: Request, + oidc: OidcAuth, + key: ApiKeyAuth, +) -> ResolvedLaunch: + """Select the request schema, validate against it, and return the result. + + The media type selects a domain or file launch, while the caller’s access state + selects the authenticated or guest domain variant. If the caller sends a field + from the wrong access state, validation identifies the correct field. + """ + media_type = (request.headers.get("content-type") or "").split(";")[0].strip().lower() + authenticated = bool(oidc or key) + + if media_type == MULTIPART_MEDIA_TYPE: + form = await request.form() + if "file" not in form: + raise _invalid(("body", "file"), "A file part is required.") + return ResolvedLaunch( + body=FileScanLaunch(file=str(form["file"])), authenticated=authenticated + ) + + if media_type != JSON_MEDIA_TYPE: + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail=( + f"Unsupported media type {media_type!r}. Use {JSON_MEDIA_TYPE} for " + f"a domain scan or {MULTIPART_MEDIA_TYPE} for a file scan." + ), + ) + + try: + payload = await request.json() + except ValueError as exc: + raise _invalid(("body",), "Body is not valid JSON.") from exc + + if not isinstance(payload, dict): + raise _invalid(("body",), "Body must be a JSON object.") + + if authenticated: + if "target" in payload: + raise _invalid( + ("body", "target"), + "An authenticated launch carries `asset_id`, not `target`.", + ) + return ResolvedLaunch( + body=_validate(AssetScanLaunch, payload), authenticated=True + ) + + if "asset_id" in payload: + raise _invalid( + ("body", "asset_id"), + "An unauthenticated launch carries `target`, not `asset_id`.", + ) + return ResolvedLaunch(body=_validate(GuestScanLaunch, payload), authenticated=False) + + +# Route dependency. Yields a validated launch request together with the access +# state that selected its schema. +ScanLaunchBody = Annotated[ResolvedLaunch, Depends(resolve_launch_body)] diff --git a/src/nc3_testing_platform/domains/scans/examples.py b/src/nc3_testing_platform/domains/scans/examples.py new file mode 100644 index 0000000..39f2c8f --- /dev/null +++ b/src/nc3_testing_platform/domains/scans/examples.py @@ -0,0 +1,329 @@ +"""Deterministic sample data for the mock backend. + +Every identifier and timestamp is fixed. The generated document is byte-stable +across runs, and a spec diff shows real contract changes rather than churn. +""" + +from datetime import UTC, datetime +from uuid import UUID + +from nc3_testing_platform.core.config import RETENTION_EXTENSION +from nc3_testing_platform.core.enums import ( + FindingSeverity, + FindingStatus, + ScanClassification, + ScanGrade, + ScanJobStatus, + ScanModule, + ScanSource, + ScanTaskStatus, + TrendDirection, +) +from nc3_testing_platform.core.schemas import SeverityCounts +from nc3_testing_platform.domains.scans.schemas import ( + Finding, + ResultTrend, + ScanJob, + ScanJobAccepted, + ScanJobDetail, + ScanResult, + ScanTask, +) + +_T0 = datetime(2026, 7, 31, 9, 0, tzinfo=UTC) +# Guest retention deadline: creation plus 24 hours. The interval is platform +# configuration rather than contract, so only the resulting timestamp is shown. +_GUEST_PURGE_AT = datetime(2026, 8, 1, 9, 0, tzinfo=UTC) +_T1 = datetime(2026, 7, 31, 9, 0, 4, tzinfo=UTC) +_T2 = datetime(2026, 7, 31, 9, 1, 12, tzinfo=UTC) +# finished_at + 12 months + 30 days +_PURGE_AT = datetime(2027, 8, 30, 9, 1, 12, tzinfo=UTC) + +ORGANIZATION_ID = UUID("019ed068-b8f8-7e25-8902-35e3ed567f57") +USER_ID = UUID("019ed068-f263-7683-bcce-ef76973414db") +ASSET_ID = UUID("019ee1a0-1c44-7a10-9d2e-4b7c8f0a1e33") +JOB_ID = UUID("019ee1a0-3b91-7c05-8f41-6d2a90bb17c4") +UPLOAD_ID = UUID("019ee1a0-2a78-7b93-8c1d-3f5e6a7b8c9d") + +_TASK_HEADERS = UUID("019ee1a0-4d02-7e18-b3a7-8c15ffd2a091") +_TASK_TLS = UUID("019ee1a0-5e13-7f29-a4b8-9d26aae3b1a2") +_TASK_SUBDOMAINS = UUID("019ee1a0-6f24-7a3a-b5c9-ae37bbf4c2b3") +_TASK_EMAIL = UUID("019ee1a0-7035-7b4b-c6da-bf48ccf5d3c4") +_TASK_PQC = UUID("019ee1a0-8146-7c5c-d7eb-c059ddf6e4d5") +_TASK_DNSSEC = UUID("019ee1a0-9257-7d6d-e8fc-d16aeef7f5e6") + +_RESULT_HEADERS = UUID("019ee1a1-0368-7e7e-f90d-e27bfff806f7") +_RESULT_EMAIL = UUID("019ee1a1-1479-7f8f-0a1e-f38c001917a8") +_PRIOR_RESULT_EMAIL = UUID("019ec3b2-58a1-7c40-9e12-7ab34cd56e89") + +_FINDING_HSTS = UUID("019ee1a1-258a-7a90-1b2f-049d112a28b9") +_FINDING_DMARC = UUID("019ee1a1-369b-7ba1-2c30-15ae223b39ca") +_FINDING_SPF = UUID("019ee1a1-47ac-7cb2-3d41-26bf334c4adb") + +FILE_JOB_ID = UUID("019ee1a2-4c60-7d81-9e23-5ab41cd67f90") +_FILE_TASK_HASHLOOKUP = UUID("019ee1a2-5d71-7e92-af34-6bc52de78a01") +_FILE_TASK_PANDORA = UUID("019ee1a2-6e82-7fa3-b045-7cd63ef89b12") +_FILE_TASK_METADATA = UUID("019ee1a2-7f93-7ab4-c156-8de74fa9ac23") +_FILE_TASK_MIME = UUID("019ee1a2-80a4-7bc5-d267-9ef85fabbd34") + + +def _task( + task_id: UUID, + module: ScanModule, + test_key: str, + status: ScanTaskStatus = ScanTaskStatus.COMPLETED, + status_reason: str | None = None, +) -> ScanTask: + return ScanTask( + id=task_id, + scan_job_id=JOB_ID, + module=module, + test_key=test_key, + test_version="1.4.0", + classification=ScanClassification.NON_INTRUSIVE, + target_asset_id=ASSET_ID, + status=status, + status_reason=status_reason, + created_at=_T0, + started_at=_T1, + finished_at=_T2 if status != ScanTaskStatus.RUNNING else None, + ) + + +def sample_tasks() -> list[ScanTask]: + """The six tasks an all-in-one domain scan fans out into. + + One is `blocked` on purpose: `status_reason` is mandatory in that state, and the + UI has to be able to tell the user why a check did not run. + """ + return [ + _task(_TASK_EMAIL, ScanModule.EMAIL, "email.mailvalidator"), + _task(_TASK_HEADERS, ScanModule.WEB, "web.headers"), + _task(_TASK_TLS, ScanModule.WEB, "web.tls"), + _task(_TASK_SUBDOMAINS, ScanModule.WEB, "web.subdomain_enumeration"), + _task(_TASK_PQC, ScanModule.PQC, "pqc.quantumvalidator"), + _task( + _TASK_DNSSEC, + ScanModule.DNSSEC, + "dnssec.chainvalidator", + status=ScanTaskStatus.BLOCKED, + status_reason="dnssec.resolver_unavailable", + ), + ] + + +def sample_file_tasks() -> list[ScanTask]: + """The four tasks a File scan fans out into. + + Every one is `not_applicable`: a File test analyzes an upload, so there is no + external target for the intrusive classification to apply to. They carry + `file_upload_id` where a domain task carries `target_asset_id`. + """ + return [ + ScanTask( + id=task_id, + scan_job_id=FILE_JOB_ID, + module=ScanModule.FILE, + test_key=test_key, + test_version="1.2.0", + classification=ScanClassification.NOT_APPLICABLE, + file_upload_id=UPLOAD_ID, + status=ScanTaskStatus.COMPLETED, + created_at=_T0, + started_at=_T1, + finished_at=_T2, + ) + for task_id, test_key in ( + (_FILE_TASK_HASHLOOKUP, "file.hashlookup"), + (_FILE_TASK_PANDORA, "file.pandora"), + (_FILE_TASK_METADATA, "file.metadata"), + (_FILE_TASK_MIME, "file.mime_check"), + ) + ] + + +def sample_file_job() -> ScanJob: + """A completed File scan. + + Carries `file_upload_id` where a domain scan carries `asset_id`, and no grade + anywhere: no File test produces one. + """ + return ScanJob( + id=FILE_JOB_ID, + organization_id=ORGANIZATION_ID, + source=ScanSource.MANUAL, + triggered_by_user_id=USER_ID, + file_upload_id=UPLOAD_ID, + modules=[ScanModule.FILE], + status=ScanJobStatus.COMPLETED, + purge_at=_PURGE_AT, + created_at=_T0, + started_at=_T1, + finished_at=_T2, + ) + + +def sample_file_job_detail() -> ScanJobDetail: + """The File scan with its four tasks.""" + return ScanJobDetail(**sample_file_job().model_dump(), tasks=sample_file_tasks()) + + +def sample_job( + status: ScanJobStatus = ScanJobStatus.PARTIAL, + source: ScanSource = ScanSource.MANUAL, + extended: bool = False, +) -> ScanJob: + """A completed all-in-one scan of an owned asset. + + `partial` rather than `completed`: one task is blocked, so usable results exist + alongside a failure. That combination is the one clients most often get wrong. + """ + return ScanJob( + id=JOB_ID, + organization_id=ORGANIZATION_ID, + source=source, + triggered_by_user_id=USER_ID, + asset_id=ASSET_ID, + modules=[ + ScanModule.EMAIL, + ScanModule.WEB, + ScanModule.PQC, + ScanModule.DNSSEC, + ], + status=status, + status_reason="scan.partial_blocked_task", + purge_at=_PURGE_AT + RETENTION_EXTENSION if extended else _PURGE_AT, + created_at=_T0, + started_at=_T1, + finished_at=_T2, + ) + + +def sample_job_detail() -> ScanJobDetail: + """The job/task snapshot a live-progress client fetches before subscribing.""" + return ScanJobDetail(**sample_job().model_dump(), tasks=sample_tasks()) + + +def queued_job_accepted( + guest: bool = False, file_scan: bool = False +) -> ScanJobAccepted: + """The `202` body of a launch, before any task has started. + + Exactly one target field is set, and which one is decided by the request context + rather than by the caller — a multipart launch carries an upload, an anonymous + JSON launch a bare domain, an authenticated JSON launch an Asset. The guest + branch is also the only one that returns a claim capability. + """ + job = ScanJobAccepted( + id=JOB_ID, + organization_id=None if guest else ORGANIZATION_ID, + source=ScanSource.GUEST if guest else ScanSource.MANUAL, + triggered_by_user_id=None if guest else USER_ID, + asset_id=None if (guest or file_scan) else ASSET_ID, + target_domain="example.lu" if (guest and not file_scan) else None, + file_upload_id=UPLOAD_ID if file_scan else None, + modules=_requested_modules(guest=guest, file_scan=file_scan), + status=ScanJobStatus.QUEUED, + # An unclaimed guest job carries its deadline from creation; an owned job + # has none until it finishes. The claim recomputes it under the normal rule. + purge_at=_GUEST_PURGE_AT if guest else None, + created_at=_T0, + ) + if guest: + # 256 bits, base64url, no padding — 43 characters. + job.claim_token = "9xK2mQ7pL4vR8nT1jH5gF3dS6aW0zYbUcElOnAiKrXs" + return job + + +def _requested_modules(*, guest: bool, file_scan: bool) -> list[ScanModule]: + """A file launch is always the File module, and the multipart request carries no module field.""" + if file_scan: + return [ScanModule.FILE] + if guest: + return [ScanModule.EMAIL] + return [ScanModule.EMAIL, ScanModule.WEB, ScanModule.PQC, ScanModule.DNSSEC] + + +def sample_results() -> list[ScanResult]: + """Results for the two graded tests in the sample job. + + `raw_output` is deliberately shallow. Its real shape belongs to the + executable-test registry, and inventing a rich one here would put a shape into + the frontend's mock that the registry has never agreed to. + """ + return [ + ScanResult( + id=_RESULT_EMAIL, + scan_task_id=_TASK_EMAIL, + schema_version="2026-05-01", + raw_output={"spf": "pass", "dkim": "pass", "dmarc": "p=none"}, + summary={"policy_enforced": False}, + grade=ScanGrade.B, + severity_counts=SeverityCounts(medium=1, info=1), + trend=ResultTrend( + previous_scan_result_id=_PRIOR_RESULT_EMAIL, + direction=TrendDirection.IMPROVING, + delta=1, + ), + completed_at=_T2, + ), + ScanResult( + id=_RESULT_HEADERS, + scan_task_id=_TASK_HEADERS, + schema_version="2026-05-01", + raw_output={ + "strict_transport_security": None, + "content_security_policy": "present", + }, + summary={"missing_headers": 1}, + grade=ScanGrade.C, + severity_counts=SeverityCounts(medium=1), + completed_at=_T2, + ), + ] + + +def sample_findings() -> list[Finding]: + """Findings across both sample results, covering all three status kinds a client must render differently.""" + return [ + Finding( + id=_FINDING_DMARC, + scan_result_id=_RESULT_EMAIL, + check_id="email.dmarc.policy_enforced", + severity=FindingSeverity.MEDIUM, + status=FindingStatus.PERSISTENT, + title="DMARC policy is not enforced", + description=( + "The domain publishes a DMARC record with p=none, so receivers " + "take no action on messages that fail authentication." + ), + affected_resource="_dmarc.example.lu", + remediation="Move to p=quarantine, then to p=reject once reports are clean.", + external_references=["RFC 7489"], + ), + Finding( + id=_FINDING_SPF, + scan_result_id=_RESULT_EMAIL, + check_id="email.spf.present", + severity=FindingSeverity.INFO, + status=FindingStatus.RESOLVED, + title="SPF record present", + description="A syntactically valid SPF record was found.", + affected_resource="example.lu", + ), + Finding( + id=_FINDING_HSTS, + scan_result_id=_RESULT_HEADERS, + check_id="web.headers.hsts_missing", + severity=FindingSeverity.MEDIUM, + status=FindingStatus.REGRESSION, + title="Strict-Transport-Security header missing", + description=( + "The response carries no HSTS header, so a client may be " + "downgraded to plaintext on a first or stale connection." + ), + affected_resource="https://example.lu/", + remediation="Send Strict-Transport-Security with a max-age of at least 31536000.", + external_references=["RFC 6797"], + ), + ] diff --git a/src/nc3_testing_platform/domains/scans/models.py b/src/nc3_testing_platform/domains/scans/models.py new file mode 100644 index 0000000..5da731b --- /dev/null +++ b/src/nc3_testing_platform/domains/scans/models.py @@ -0,0 +1,15 @@ +"""SQLAlchemy models for scan execution. + +Tables owned by this domain are specified in `docs/reference/data-model-v4_0_1.md`: +`scan_job` (§7.1), `scan_task` (§7.2), and `scan_result` (§8.1). + +Nothing here is implemented yet. +SQLAlchemy is not a project dependency, and no migration tooling is configured. +""" + +# TODO: declare ScanJob per data-model §7.1, including the exactly-one-target +# constraint over asset_id, target_domain, and file_upload_id. +# TODO: declare ScanTask per data-model §7.2. Test key, version, and +# classification are copied at creation and immutable thereafter. +# TODO: declare ScanResult per data-model §8.1, at most one per task. +# TODO: decide where the declarative base lives once a second domain needs it. \ No newline at end of file diff --git a/src/nc3_testing_platform/domains/scans/repository.py b/src/nc3_testing_platform/domains/scans/repository.py new file mode 100644 index 0000000..f0e11bd --- /dev/null +++ b/src/nc3_testing_platform/domains/scans/repository.py @@ -0,0 +1,17 @@ +"""Database queries for scan execution. + +Every function takes the session as its first argument and returns models or plain +values. +Nothing here opens a transaction, commits, or raises HTTP errors; those belong to +`service.py` and `router.py` respectively. + +Nothing here is implemented yet. +""" + +# TODO: get_job(session, scan_id) and get_task(session, task_id). +# TODO: list_jobs(session, ...) with cursor pagination, ordered stably. +# TODO: insert_job_with_tasks(session, ...) creating both in one flush. +# TODO: claim_job(session, scan_id, token_hash) as a single conditional update: +# unclaimed guest job, matching hash, deadline not passed. +# TODO: list_results(session, scan_id) and the finding filters in api-design §6. +# TODO: mark_cancellation_requested(session, task_id). diff --git a/src/nc3_testing_platform/domains/scans/router.py b/src/nc3_testing_platform/domains/scans/router.py new file mode 100644 index 0000000..89cc337 --- /dev/null +++ b/src/nc3_testing_platform/domains/scans/router.py @@ -0,0 +1,323 @@ +"""Scan launch, lifecycle, live progress, retention, and deletion. + +This domain exercises every cross-cutting pattern in the contract — media-type +request dispatch, optional authentication, the `202` async-job shape, cursor +pagination, an SSE channel, problem+json errors, rate-limit headers, and hard +deletion. + +Handlers return fixed sample data. The gates listed in :func:`launch_scan` are +application logic. +""" + +from collections.abc import Iterator +from typing import Any + +from fastapi import APIRouter, Response, status +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from nc3_testing_platform.core.enums import ScanJobStatus +from nc3_testing_platform.core.errors import problem_responses +from nc3_testing_platform.core.pagination import CursorPage, Page +from nc3_testing_platform.core.schemas import ResourceId +from nc3_testing_platform.core.security import ( + ANONYMOUS_ALTERNATIVE, + Authenticated, + OptionallyAuthenticated, + rate_limited, +) +from nc3_testing_platform.domains.scans import examples +from nc3_testing_platform.domains.scans.dependencies import ( + JSON_MEDIA_TYPE, + MULTIPART_MEDIA_TYPE, + ScanAccessToken, + ScanLaunchBody, +) +from nc3_testing_platform.domains.scans.schemas import ( + FileScanLaunch, + ScanClaimRequest, + ScanEndEvent, + ScanHeartbeatEvent, + ScanJob, + ScanJobAccepted, + ScanJobDetail, + ScanJobEvent, + ScanResult, + ScanTaskEvent, +) + +router = APIRouter( + prefix="/scans", + tags=["scans"], +) + +# Handwritten because FastAPI generates one request schema per operation from +# type hints, and this operation has three. The variants are registered as +# components by :func:`app.core.openapi.register_component_schemas`, so these +# references resolve to named types in a generated client. +# +# The `oneOf` carries no discriminator on purpose: the selector is the caller's +# access state, not a field in the body, and OpenAPI cannot express that. The +# description states the rule the document cannot. +LAUNCH_REQUEST_BODY: dict[str, Any] = { + "required": True, + "description": ( + "The request schema is selected by media type, and within JSON by access " + "state.\n\n" + "- `application/json` + authenticated caller → `AssetScanLaunch` " + "(`asset_id`, an Asset in the caller's organization).\n" + "- `application/json` + anonymous caller → `GuestScanLaunch` (`target`, a " + "canonical domain).\n" + "- `multipart/form-data` → `FileScanLaunch` (one `file` part, no target).\n\n" + "Supplying the field belonging to the other access state returns `422`. No " + "schema contains an `asset_id | target | file` union, and no other media " + "type is accepted." + ), + "content": { + JSON_MEDIA_TYPE: { + "schema": { + "oneOf": [ + {"$ref": "#/components/schemas/AssetScanLaunch"}, + {"$ref": "#/components/schemas/GuestScanLaunch"}, + ] + } + }, + MULTIPART_MEDIA_TYPE: { + "schema": {"$ref": "#/components/schemas/FileScanLaunch"} + }, + }, +} + +# The payload shape is selected by SSE's own `event:` line, which sits outside the +# JSON, so OpenAPI cannot discriminate the `oneOf`. The description carries the +# mapping. +_EVENT_STREAM_RESPONSE: dict[int | str, dict] = { + 200: { + "description": ( + "Advisory progress events until the job reaches a terminal state. The " + "SSE `event:` line selects the payload:\n\n" + "- `task` → `ScanTaskEvent`, one task changed state\n" + "- `job` → `ScanJobEvent`, the job changed state\n" + "- `heartbeat` → `ScanHeartbeatEvent`, sent on an interval\n" + "- `end` → `ScanEndEvent`, terminal state reached, no further events\n\n" + "Database state is authoritative. Refetch the snapshot after a reconnect " + "or whenever an applied event leaves the client uncertain." + ), + "content": { + "text/event-stream": { + "schema": { + "oneOf": [ + {"$ref": "#/components/schemas/ScanTaskEvent"}, + {"$ref": "#/components/schemas/ScanJobEvent"}, + {"$ref": "#/components/schemas/ScanHeartbeatEvent"}, + {"$ref": "#/components/schemas/ScanEndEvent"}, + ] + } + } + }, + } +} + + +@router.post( + "", + status_code=status.HTTP_202_ACCEPTED, + summary="Launch a scan", + response_model=ScanJobAccepted, + openapi_extra={ + "requestBody": LAUNCH_REQUEST_BODY, + "security": ANONYMOUS_ALTERNATIVE, + }, + responses={ + **problem_responses(403, 415, 422), + **rate_limited(), + }, +) +async def launch_scan(launch: ScanLaunchBody) -> ScanJobAccepted: + """Launch a domain or file scan. + + Returns `202` with the job resource. An unauthenticated launch also returns the + one-time token needed to claim the scan after registering. + + The application performs the launch in a fixed order: allocate the job + identifier, record any required declarations against it, evaluate the gates, + create job and task state in one transaction, enqueue only once that state is + durable, then respond. Gates — authorization, verification, current MFA + assurance, rate, and cooldown — are evaluated from the request context and the + selected tests. None of them is a field the caller sends. + """ + return examples.queued_job_accepted( + guest=not launch.authenticated, + file_scan=isinstance(launch.body, FileScanLaunch), + ) + + +@router.get( + "", + summary="List scans", + responses=problem_responses(401), + dependencies=[Authenticated], +) +async def list_scans(page: CursorPage) -> Page[ScanJob]: + """Scans in the caller's organization, newest first. + + Guest scans appear here once they are claimed. + """ + return Page( + items=[examples.sample_job(), examples.sample_file_job()], next_cursor=None + ) + + +@router.get( + "/{scan_id}", + summary="Get a scan with its task snapshot", + responses=problem_responses(404), + dependencies=[OptionallyAuthenticated], + openapi_extra={"security": ANONYMOUS_ALTERNATIVE}, +) +async def get_scan( + scan_id: ResourceId, claim_token: ScanAccessToken = None +) -> ScanJobDetail: + """The authoritative job and task state. + + Fetch this before subscribing to the event stream and again after reconnection + or whenever an applied event leaves the client uncertain. + """ + if scan_id == examples.FILE_JOB_ID: + return examples.sample_file_job_detail() + return examples.sample_job_detail() + + +@router.get( + "/{scan_id}/results", + summary="Get scan results", + responses=problem_responses(404), + dependencies=[OptionallyAuthenticated], + openapi_extra={"security": ANONYMOUS_ALTERNATIVE}, +) +async def get_scan_results( + scan_id: ResourceId, claim_token: ScanAccessToken = None +) -> list[ScanResult]: + """One result per completed task. + + Not paginated: the collection is bounded by the job's task count, which the + executable-test catalog caps well below a page. + """ + return examples.sample_results() + + +@router.get( + "/{scan_id}/events", + summary="Stream live scan progress", + response_class=StreamingResponse, + responses={**_EVENT_STREAM_RESPONSE, **problem_responses(404)}, + dependencies=[OptionallyAuthenticated], + openapi_extra={"security": ANONYMOUS_ALTERNATIVE}, +) +async def stream_scan_events( + scan_id: ResourceId, claim_token: ScanAccessToken = None +) -> StreamingResponse: + """Advisory server-sent events for a running scan. + + Database state is authoritative; these events only reduce latency. + The snapshot is the only recovery for missed events: refetch it after a reconnect. + """ + + def event_stream() -> Iterator[str]: + for name, event in _sample_events(): + yield f"event: {name}\ndata: {event.model_dump_json()}\n\n" + + return StreamingResponse(event_stream(), media_type="text/event-stream") + + +def _sample_events() -> list[tuple[str, BaseModel]]: + job = examples.sample_job() + stream: list[tuple[str, BaseModel]] = [ + ("job", ScanJobEvent(status=ScanJobStatus.RUNNING, occurred_at=job.created_at)) + ] + for task in examples.sample_tasks(): + stream.append( + ( + "task", + ScanTaskEvent( + task_id=task.id, + status=task.status, + status_reason=task.status_reason, + occurred_at=task.finished_at or task.created_at, + ), + ) + ) + ended = job.finished_at or job.created_at + stream.append(("heartbeat", ScanHeartbeatEvent(occurred_at=ended))) + stream.append(("job", ScanJobEvent(status=job.status, occurred_at=ended))) + stream.append(("end", ScanEndEvent(status=job.status, occurred_at=ended))) + return stream + + +@router.post( + "/{scan_id}/cancel", + summary="Cancel a running scan", + responses=problem_responses(401, 404, 409), + dependencies=[Authenticated], +) +async def cancel_scan(scan_id: ResourceId) -> ScanJob: + """Record durable cancellation intent. + + Scan history is preserved: canceling is not deletion, and `DELETE` is never + used to stop execution. Workers check the intent before starting a task and at + safe interruption points; a canceled task cannot later produce an accepted + successful result. + """ + return examples.sample_job() + + +@router.post( + "/{scan_id}/claim", + summary="Claim a guest scan", + responses=problem_responses(401, 404), + dependencies=[Authenticated], +) +async def claim_scan(scan_id: ResourceId, body: ScanClaimRequest) -> ScanJob: + """Attach a guest scan to the authenticated caller's organization. + + One atomic compare-and-set: the job must be an unclaimed guest job whose stored + hash matches the supplied token and whose retention has not lapsed. Success + records the claiming user and organization and discards the stored hash, so the + token cannot be spent twice. For a file scan it also restores organization + scoping on the upload metadata. + + Every failure answers `404` — wrong token, already claimed, and lapsed are + indistinguishable from outside. Anything more specific would let a caller + holding no token confirm that a scan exists and learn its state. + """ + return examples.sample_job() + + +@router.post( + "/{scan_id}/retention/extend", + summary="Extend the retention deadline", + responses=problem_responses(401, 403, 404, 409), + dependencies=[Authenticated], +) +async def extend_retention(scan_id: ResourceId) -> ScanJob: + """Move `purge_at` further out and record an audit event. + + No request body: the interval is policy, not contract. + Read the new deadline from the response. + """ + return examples.sample_job(extended=True) + + +@router.delete( + "/{scan_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Hard-delete a scan", + responses=problem_responses(401, 403, 404), + dependencies=[Authenticated], +) +async def delete_scan(scan_id: ResourceId) -> Response: + """Delete the scan and its data immediately. + + Distinct from cancellation, which stops execution and keeps the history. + """ + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/src/nc3_testing_platform/domains/scans/schemas.py b/src/nc3_testing_platform/domains/scans/schemas.py new file mode 100644 index 0000000..8ffbc47 --- /dev/null +++ b/src/nc3_testing_platform/domains/scans/schemas.py @@ -0,0 +1,485 @@ +"""Scan launch, execution, results, and findings. + +Shape: + ScanJob 1─n ScanTask 1─0..1 ScanResult 1─n Finding + +A ScanJob is one submitted request. It fans out into one ScanTask per executable +test and each task queues, fails, and is graded independently. A result belongs to +a task. + +MVP: **Open payloads.** Several columns are JSONB whose element shapes belong to the +scan modules, which do not exist yet. Each module will own its result schema; once +a module is written and wired, its schema is imported and composed into the result +envelope here, and the generated contract gains typed results. +Until then those payloads are untyped. +Each such field is marked `TODO` naming its owner and what unblocks it. +""" + +from typing import Any + +from pydantic import BaseModel, Field, field_validator + +from nc3_testing_platform.core.enums import ( + FindingSeverity, + FindingStatus, + ScanClassification, + ScanGrade, + ScanJobStatus, + ScanModule, + ScanSource, + ScanTaskStatus, + TrendDirection, +) +from nc3_testing_platform.core.schemas import ( + BaseSchema, + DomainName, + ResourceId, + SeverityCounts, + Timestamp, +) +from nc3_testing_platform.domains.statements.schemas import StatementResponseSubmission + +_STATEMENT_RESPONSES_DESCRIPTION = ( + "Answers to the statements this launch requires, each identified by key and " + "version. Recorded as immutable receipts bound to the job before any gate is " + "evaluated. Empty in practice for v4.0: a declaration is required only by an " + "intrusive test, and the v4.0 catalog classifies none. A boolean " + "`attestation` flag is not a substitute and is not part of this contract." +) + +# The v4.0 executable tests. Not an enum: `test_key` is namespaced text whose +# vocabulary is owned by application code and extends without a migration. Listed +# here so the contract can document and exemplify the current catalog. +V4_TEST_KEYS = ( + "email.mailvalidator", + "web.headers", + "web.tls", + "web.subdomain_enumeration", + "file.hashlookup", + "file.pandora", + "file.metadata", + "file.mime_check", + "pqc.quantumvalidator", + "dnssec.chainvalidator", +) + +_TEST_KEY_DESCRIPTION = ( + "Stable identifier of the executable test. v4.0 catalog: " + + ", ".join(f"`{key}`" for key in V4_TEST_KEYS) + + ". The vocabulary is code-owned and extends without a schema change." +) + + +class Finding(BaseSchema): + """One diagnostic-rule outcome recorded against a scan result. + + `check_id` is the stable identity anchor used to match a finding across scans. + It is never derived from the title or from position in the output, and changing + one is a breaking result-schema change. + """ + + id: ResourceId + scan_result_id: ResourceId + check_id: str = Field( + description=( + "Stable diagnostic-rule identifier. Regression matching keys on this " + "and, where one rule yields several findings, on the normalized " + "`affected_resource`." + ) + ) + severity: FindingSeverity + status: FindingStatus = Field( + description=( + "Historical-comparison classification, derived when the result is " + "written and immutable thereafter. No operation mutates it." + ) + ) + title: str + description: str + affected_resource: str | None = Field( + default=None, + description="The specific record, host, or header the finding concerns.", + ) + remediation: str | None = None + # TODO: typed evidence is owned by the check that raises the finding, and so by + # the module that owns the check. Composed in with the module's result schema. + evidence: dict[str, Any] | None = Field( + default=None, description="Per-check evidence. Shape owned by the check." + ) + external_references: list[Any] = Field( + default_factory=list, + description="External references for this rule. Element shape owned by the check.", + ) + + +class ResultTrend(BaseSchema): + """Movement of a result's score against the previous result for the same test. + + Computed per request from the two results. `delta` uses the scale of whichever + metric was compared, so it is comparable against other deltas for the same test. + """ + + previous_scan_result_id: ResourceId = Field( + description="The result this one was compared against." + ) + direction: TrendDirection + delta: float = Field( + description=( + "Signed change, positive when improving. Grades move in whole steps " + "along `A+ A B C D F`; severity counts move by finding count." + ) + ) + + +class ScanResult(BaseSchema): + """The output of one completed ScanTask. At most one per task.""" + + id: ResourceId + scan_task_id: ResourceId + schema_version: str = Field( + description=( + "Version of the result payload written by this test. Text, not a " + "number: the registry versions each test's result schema independently." + ) + ) + # TODO: owned by the scan module that produces the result. + # One shape, or keyed shapes. + raw_output: dict[str, Any] = Field( + description="Full test output. Shape owned by the executable-test registry." + ) + summary: dict[str, Any] = Field( + default_factory=dict, + description=( + "Condensed verdicts for display. Non-graded tests carry their per-step " + "verdicts here. Shape owned by the executable-test registry." + ), + ) + grade: ScanGrade | None = Field( + default=None, + description=( + "Letter grade. Present only for `email.mailvalidator`, `web.headers`, " + "and `web.tls`. No cross-module composite score exists." + ), + ) + severity_counts: SeverityCounts | None = Field( + default=None, description="Findings by severity. Used by non-graded tests." + ) + trend: ResultTrend | None = Field( + default=None, + description=( + "Movement against the previous result for this test, tracking `grade` " + "where the test is graded and total findings where it is not. Null on " + "the first result for a test, or once the predecessor has been purged." + ), + ) + completed_at: Timestamp + + +class ScanTask(BaseSchema): + """One executable test run against one domain or one uploaded file. + + Exactly one of `target_asset_id`, `target_domain`, and `file_upload_id` is set. + `id` doubles as the queue task identifier and as the public `task_id` carried by + live-progress events. + """ + + id: ResourceId + scan_job_id: ResourceId + parent_task_id: ResourceId | None = Field( + default=None, + description=( + "Discovery and fan-out lineage. A subdomain found by " + "`web.subdomain_enumeration` becomes a child task of the discovering one." + ), + ) + module: ScanModule + test_key: str = Field(description=_TEST_KEY_DESCRIPTION) + test_version: str = Field( + description="Version of the test definition, copied at task creation." + ) + classification: ScanClassification + target_asset_id: ResourceId | None = None + target_domain: DomainName | None = Field( + default=None, + description="Set for a guest target or a discovered subdomain with no Asset row.", + ) + file_upload_id: ResourceId | None = None + # TODO: shape owned by the module, alongside its result schema. + configuration: dict[str, Any] = Field( + default_factory=dict, + description="Resolved per-test configuration. Shape owned by the test registry.", + ) + status: ScanTaskStatus + status_reason: str | None = Field( + default=None, + description=( + "Stable namespaced reason code for a failed, skipped, blocked, or " + "canceled outcome. Always present when the status is `blocked`. " + "A task timeout appears as `failed` plus the task-timeout reason: " + "timeout is a reason, never a status. Labels and localization are " + "code-owned." + ), + ) + cancellation_requested_at: Timestamp | None = Field( + default=None, + description=( + "Durable cancellation intent. Workers check it before starting and at " + "safe interruption points; a canceled task cannot later produce an " + "accepted successful result." + ), + ) + created_at: Timestamp + started_at: Timestamp | None = None + finished_at: Timestamp | None = None + + +class ScanJob(BaseSchema): + """One submitted scan request. + + Exactly one of `asset_id`, `target_domain`, and `file_upload_id` is set, chosen + by the request context rather than by the caller: an authenticated JSON launch + populates `asset_id`, an unauthenticated JSON launch populates `target_domain`, + and a multipart launch populates `file_upload_id`. + """ + + id: ResourceId + organization_id: ResourceId | None = Field( + default=None, + description=( + "Owning organization, and the row-level-security key. Null for a guest " + "job until it is claimed. Tasks, results, and findings inherit it, so " + "it is not repeated on those resources." + ), + ) + source: ScanSource = Field( + description=( + "Derived server-side from the request context, never supplied by the " + "caller. It selects which gates apply." + ) + ) + schedule_id: ResourceId | None = Field( + default=None, description="Present when `source` is `schedule`." + ) + api_key_id: ResourceId | None = Field( + default=None, description="Present when `source` is `api`." + ) + triggered_by_user_id: ResourceId | None = Field( + default=None, + description="Attribution only. Becomes null if the user is erased.", + ) + asset_id: ResourceId | None = None + target_domain: DomainName | None = Field( + default=None, + description=( + "Canonical domain that is not an Asset row. Populated only by an " + "unauthenticated launch; a guest target never becomes an Asset." + ), + ) + file_upload_id: ResourceId | None = Field( + default=None, + description="The upload created by an accepted multipart launch. At most one per job.", + ) + modules: list[ScanModule] = Field( + description=( + "What the launch asked for. Compare against the tasks to see what ran: a " + "requested module whose task was blocked or skipped produced nothing." + ) + ) + # TODO: launch options are owned by the module they configure + module_configuration: dict[str, Any] = Field(default_factory=dict) + status: ScanJobStatus + status_reason: str | None = Field( + default=None, + description=( + "Stable namespaced reason code for a job-wide exceptional or terminal " + "outcome. A job timeout sets the job-timeout reason and resolves to " + "`partial` when usable results exist, otherwise `failed`." + ), + ) + claimed_by_user_id: ResourceId | None = None + claimed_at: Timestamp | None = None + purge_at: Timestamp | None = Field( + default=None, + description=( + "Read-only final hard-deletion timestamp, not the start of a grace " + "period. Null until terminal completion, then `finished_at` plus twelve " + "months plus thirty days by default, with thirty days' notice.\n\n" + "An unclaimed guest job is the exception: it carries a deadline from " + "creation, because ownerless data has nobody to notify and no reason to " + "be kept. A successful claim recomputes this under the normal rule, and " + "notice begins to apply only from that point. Purging at the deadline " + "does not wait for the job to finish; unfinished work is terminated." + ), + ) + created_at: Timestamp + started_at: Timestamp | None = None + finished_at: Timestamp | None = None + + +class ScanJobAccepted(ScanJob): + """The `202` body of a launch. + + Identical to a ScanJob except that an unauthenticated launch also returns the + one-time token needed to claim the scan after registering. + """ + + claim_token: str | None = Field( + default=None, + description=( + "One-time token that claims this scan for an organization once the " + "guest registers. Present only on the response to an unauthenticated " + "launch, and readable only here — the server keeps a hash, so a lost " + "token cannot be recovered and the scan stays unclaimable." + ), + examples=["9xK2mQ7pL4vR8nT1jH5gF3dS6aW0zYbUcElOnAiKrXs"], + ) + + +class ScanJobDetail(ScanJob): + """A job together with its task snapshot. + + This is the snapshot a live-progress client fetches before subscribing, and + refetches after a reconnect. Results are a separate call because they are large. + """ + + tasks: list[ScanTask] = Field(default_factory=list) + + +class _DomainLaunch(BaseModel): + """What the two JSON launch variants share. + + Only the target field differs between them, and that difference is the whole + point of the access-state split — so everything else, including the rule that a + domain launch cannot request the File module, belongs here once. + + Not a component in the generated document: nothing references it, so Pydantic + inlines its fields into each variant. + """ + + modules: list[ScanModule] = Field( + min_length=1, description="One or more modules to run against the target." + ) + module_configuration: dict[str, Any] = Field( + default_factory=dict, + description=( + "Per-module launch options. Each module defines its own option shape; " + "the web module's subdomain-discovery option is one example." + ), + ) + statement_responses: list[StatementResponseSubmission] = Field( + default_factory=list, description=_STATEMENT_RESPONSES_DESCRIPTION + ) + + @field_validator("modules") + @classmethod + def _reject_file_module(cls, modules: list[ScanModule]) -> list[ScanModule]: + """The File module has no domain target to scan. + + It analyzes an upload, so it is reachable only through the multipart + transport. Accepting it here would create a job whose tasks have nothing to + run against. + """ + if ScanModule.FILE in modules: + raise ValueError( + "The `file` module analyzes an upload, not a domain. Launch it with " + "a `multipart/form-data` request instead." + ) + return modules + + +class AssetScanLaunch(_DomainLaunch): + """Authenticated domain launch. + + Selected by `application/json` plus an authenticated caller. + """ + + asset_id: ResourceId = Field( + description="An Asset belonging to the caller's organization." + ) + + +class GuestScanLaunch(_DomainLaunch): + """Unauthenticated domain launch. + + Selected by `application/json` plus an anonymous caller. + + Free target text exists in this context and nowhere else. The domain is stored + on the job and never becomes an Asset row. Guest launches run non-intrusive + tests only, which in v4.0 is every domain test. + """ + + target: DomainName = Field( + description=( + "Domain to scan. Unicode or ASCII input is accepted and canonicalized " + "to lowercase IDNA (A-label) form without a trailing dot." + ), + examples=["example.lu"], + ) + + +class FileScanLaunch(BaseModel): + """File launch. Selected by `multipart/form-data`. + + Carries no target field and no `modules` field. + The resulting job is always the File module. + """ + + file: str = Field( + json_schema_extra={"contentMediaType": "application/octet-stream"}, + description=( + "The file to analyze. Maximum 50 MB by default. The MIME type is " + "detected from the raw bytes; the declared `Content-Type` and the " + "filename extension are not trusted." + ), + ) + module_configuration: dict[str, Any] = Field(default_factory=dict) + + +class ScanClaimRequest(BaseModel): + """Claims a guest scan for the authenticated caller's organization. + + The token travels in the body rather than a header: it authorizes one operation + on one resource, not the caller, and `Authorization` already carries the session + that identifies who is claiming. + """ + + claim_token: str = Field( + description="The one-time token returned by the unauthenticated launch.", + examples=["9xK2mQ7pL4vR8nT1jH5gF3dS6aW0zYbUcElOnAiKrXs"], + ) + + +class ScanTaskEvent(BaseModel): + """SSE `task`: one task changed state.""" + + task_id: ResourceId + status: ScanTaskStatus + status_reason: str | None = Field( + default=None, description="Present on a terminal status." + ) + occurred_at: Timestamp + + +class ScanJobEvent(BaseModel): + """SSE `job`: the job changed state.""" + + status: ScanJobStatus + status_reason: str | None = None + occurred_at: Timestamp + + +class ScanHeartbeatEvent(BaseModel): + """SSE `heartbeat`: the stream is alive. + + Sent on an interval so a client can tell a running scan from a dropped + connection, and show when it last heard anything. + """ + + occurred_at: Timestamp + + +class ScanEndEvent(BaseModel): + """SSE `end`: the job reached a terminal state and no further events follow.""" + + status: ScanJobStatus + occurred_at: Timestamp diff --git a/src/nc3_testing_platform/domains/scans/service.py b/src/nc3_testing_platform/domains/scans/service.py new file mode 100644 index 0000000..cc8e8ea --- /dev/null +++ b/src/nc3_testing_platform/domains/scans/service.py @@ -0,0 +1,23 @@ +"""Business logic for scan execution. + +Owns the launch sequence, the gates that run before a ScanJob exists, and the +transaction boundary around creation. +Handlers in `router.py` translate the results into responses; queries live in +`repository.py`. + +Nothing here is implemented yet. +""" + +# TODO: launch_domain_scan(...) following the six-step order in +# docs/architecture/scan-launch-and-upload-handling.md §1. The identifier is +# generated first so declarations can bind to it, and tasks are enqueued only +# after the creating transaction commits. +# TODO: launch_file_scan(...) creating the upload, job, and initial tasks in one +# step, after raw-byte MIME validation. +# TODO: evaluate_launch_gates(...) covering authorization, verification, current +# MFA assurance, rate and cooldown, and required declarations. +# TODO: claim_guest_scan(...) transferring ownership of the job and, for a file +# scan, of the upload record. Every failure answers 404 (api-design §2.3). +# TODO: request_cancellation(...) recording durable intent, per data-model §7.2. +# TODO: recompute_purge_at(...) on terminal completion and on successful claim, +# per api-design §11. diff --git a/src/nc3_testing_platform/domains/schedules/__init__.py b/src/nc3_testing_platform/domains/schedules/__init__.py new file mode 100644 index 0000000..d656d34 --- /dev/null +++ b/src/nc3_testing_platform/domains/schedules/__init__.py @@ -0,0 +1 @@ +"""Recurring scan management.""" diff --git a/src/nc3_testing_platform/domains/schedules/router.py b/src/nc3_testing_platform/domains/schedules/router.py new file mode 100644 index 0000000..07dc914 --- /dev/null +++ b/src/nc3_testing_platform/domains/schedules/router.py @@ -0,0 +1,101 @@ +"""Recurring scan management.""" + +from datetime import UTC, datetime +from uuid import UUID + +from fastapi import APIRouter, Response, status + +from nc3_testing_platform.core.enums import ScanModule +from nc3_testing_platform.core.errors import problem_responses +from nc3_testing_platform.core.pagination import CursorPage, Page +from nc3_testing_platform.core.schemas import ResourceId +from nc3_testing_platform.core.security import Authenticated +from nc3_testing_platform.domains.scans.examples import ( + ASSET_ID, + ORGANIZATION_ID, + USER_ID, +) +from nc3_testing_platform.domains.schedules.schemas import ( + Schedule, + ScheduleCreate, + ScheduleUpdate, +) + +router = APIRouter( + prefix="/schedules", + tags=["schedules"], +) + +_SCHEDULE_ID = UUID("019ee1a4-0011-7a22-8b33-4c44d5e66f77") + + +def _sample_schedule() -> Schedule: + return Schedule( + id=_SCHEDULE_ID, + organization_id=ORGANIZATION_ID, + asset_id=ASSET_ID, + created_by_user_id=USER_ID, + modules=[ScanModule.EMAIL, ScanModule.WEB, ScanModule.DNSSEC], + recurrence_rule="FREQ=WEEKLY;BYDAY=MO;BYHOUR=2;BYMINUTE=0", + timezone="Europe/Luxembourg", + next_run_at=datetime(2026, 8, 3, 0, 0, tzinfo=UTC), + created_at=datetime(2026, 6, 1, 8, 30, tzinfo=UTC), + updated_at=datetime(2026, 7, 31, 9, 1, 12, tzinfo=UTC), + ) + + +@router.get( + "", + summary="List schedules", + responses=problem_responses(401), + dependencies=[Authenticated], +) +async def list_schedules(page: CursorPage) -> Page[Schedule]: + """Schedules owned by the caller's organization.""" + return Page(items=[_sample_schedule()], next_cursor=None) + + +@router.post( + "", + status_code=status.HTTP_201_CREATED, + summary="Create a schedule", + responses=problem_responses(401, 403, 404, 422), + dependencies=[Authenticated], +) +async def create_schedule(body: ScheduleCreate) -> Schedule: + """Create a recurring scan against a currently eligible asset.""" + return _sample_schedule() + + +@router.get( + "/{schedule_id}", + summary="Get a schedule", + responses=problem_responses(401, 404), + dependencies=[Authenticated], +) +async def get_schedule(schedule_id: ResourceId) -> Schedule: + """One schedule, including its next fire time.""" + return _sample_schedule() + + +@router.patch( + "/{schedule_id}", + summary="Update a schedule", + responses=problem_responses(401, 404, 422), + dependencies=[Authenticated], +) +async def update_schedule(schedule_id: ResourceId, body: ScheduleUpdate) -> Schedule: + """Change recurrence, modules, or enablement.""" + return _sample_schedule() + + +@router.delete( + "/{schedule_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a schedule", + responses=problem_responses(401, 404), + dependencies=[Authenticated], +) +async def delete_schedule(schedule_id: ResourceId) -> Response: + """Remove a schedule. Scans it already produced are unaffected.""" + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/src/nc3_testing_platform/domains/schedules/schemas.py b/src/nc3_testing_platform/domains/schedules/schemas.py new file mode 100644 index 0000000..85ee28e --- /dev/null +++ b/src/nc3_testing_platform/domains/schedules/schemas.py @@ -0,0 +1,80 @@ +"""Recurring scans, shared across an organization. + +Recurrence is an RFC 5545 rule plus an IANA timezone, not a frequency enum with a +weekday and day-of-month. This handles patterns an enum cannot express (last +weekday of a month, every second Tuesday) and preserves local run times across DST +boundaries — a 02:00, a run stays at 02:00 local time. + +A schedule creates ScanJob rows and stores no results of its own. +""" + +from typing import Any + +from pydantic import BaseModel, Field + +from nc3_testing_platform.core.enums import ScanModule +from nc3_testing_platform.core.schemas import BaseSchema, ResourceId, Timestamp + +_RECURRENCE_DESCRIPTION = ( + "RFC 5545 RRULE, without the `RRULE:` prefix, e.g. " + "`FREQ=WEEKLY;BYDAY=MO;BYHOUR=2;BYMINUTE=0`." +) +_TIMEZONE_DESCRIPTION = ( + "IANA timezone the rule is evaluated in, e.g. `Europe/Luxembourg`. Stored " + "separately from the rule so local run times survive daylight-saving changes." +) + + +class Schedule(BaseSchema): + """A recurring scan of one asset.""" + + id: ResourceId + organization_id: ResourceId + asset_id: ResourceId + created_by_user_id: ResourceId | None = Field( + default=None, + description="Attribution only. The schedule is organization-owned.", + ) + modules: list[ScanModule] + module_configuration: dict[str, Any] = Field(default_factory=dict) + recurrence_rule: str = Field(description=_RECURRENCE_DESCRIPTION) + timezone: str = Field(description=_TIMEZONE_DESCRIPTION) + enabled: bool = True + next_run_at: Timestamp | None = Field( + default=None, + description="Next fire time. Null while disabled or once the rule is exhausted.", + ) + created_at: Timestamp + updated_at: Timestamp + + +class ScheduleCreate(BaseModel): + """Create a recurring scan. + + The asset must already be eligible under the verification rules. Eligibility is + rechecked at every execution rather than trusted from creation time, so a + verification that lapses stops producing scans instead of quietly continuing. + """ + + asset_id: ResourceId + modules: list[ScanModule] = Field(min_length=1) + module_configuration: dict[str, Any] = Field(default_factory=dict) + recurrence_rule: str = Field(description=_RECURRENCE_DESCRIPTION) + timezone: str = Field(description=_TIMEZONE_DESCRIPTION) + enabled: bool = True + + +class ScheduleUpdate(BaseModel): + """Partial update. Omitted fields are left alone. + + `asset_id` is absent: repointing a schedule at another asset would attribute one + asset's recurring history to a different one. + """ + + modules: list[ScanModule] | None = Field(default=None, min_length=1) + module_configuration: dict[str, Any] | None = None + recurrence_rule: str | None = Field( + default=None, description=_RECURRENCE_DESCRIPTION + ) + timezone: str | None = Field(default=None, description=_TIMEZONE_DESCRIPTION) + enabled: bool | None = None diff --git a/src/nc3_testing_platform/domains/statements/__init__.py b/src/nc3_testing_platform/domains/statements/__init__.py new file mode 100644 index 0000000..7fd65c9 --- /dev/null +++ b/src/nc3_testing_platform/domains/statements/__init__.py @@ -0,0 +1 @@ +"""Statement discovery and account-level acceptance.""" diff --git a/src/nc3_testing_platform/domains/statements/router.py b/src/nc3_testing_platform/domains/statements/router.py new file mode 100644 index 0000000..7ffa179 --- /dev/null +++ b/src/nc3_testing_platform/domains/statements/router.py @@ -0,0 +1,78 @@ +"""Statement discovery and account-level acceptance.""" + +from datetime import UTC, datetime +from uuid import UUID + +from fastapi import APIRouter, status + +from nc3_testing_platform.core.enums import StatementResponseKind +from nc3_testing_platform.core.errors import problem_responses +from nc3_testing_platform.core.security import Authenticated +from nc3_testing_platform.domains.statements.schemas import ( + Statement, + StatementResponseReceipt, + StatementResponseSubmission, +) + +router = APIRouter(tags=["statements"]) + +_T0 = datetime(2026, 1, 15, tzinfo=UTC) +_STATEMENT_ID = UUID("019ee1a2-0011-7c22-8d33-4e55f6a77b88") +_RECEIPT_ID = UUID("019ee1a2-1122-7d33-9e44-5f66a7b88c99") + + +@router.get( + "/statements", + summary="List active statements", + responses=problem_responses(500), +) +async def list_statements() -> list[Statement]: + """Statements currently in force. + + Unauthenticated: a visitor has to be able to read the terms before there is an + account to attach an acceptance to. + """ + return [ + Statement( + id=_STATEMENT_ID, + statement_key="terms_and_conditions", + version="2026-01-15", + response_kind=StatementResponseKind.ACCEPTANCE, + content_hash="sha256:2f8a1c9d4e7b0a3f6c5d8e1b4a7f0c3d6e9b2a5f8c1d4e7b0a3f6c5d8e1b4a7f", + content_uri="https://testing.nc3.lu/legal/terms/2026-01-15", + effective_at=_T0, + ), + Statement( + id=UUID("019ee1a2-2233-7e44-af55-6a77b899cdaa"), + statement_key="scan_target_permission", + version="2026-01-15", + response_kind=StatementResponseKind.ATTESTATION, + required_context_type="scan_job", + content_hash="sha256:7b0a3f6c5d8e1b4a7f0c3d6e9b2a5f8c1d4e7b0a3f6c5d8e1b4a7f2f8a1c9d4e", + content_uri="https://testing.nc3.lu/legal/scan-permission/2026-01-15", + effective_at=_T0, + ), + ] + + +@router.post( + "/statement-responses", + status_code=status.HTTP_201_CREATED, + summary="Record an account-level response", + responses=problem_responses(401, 404, 409, 422), + dependencies=[Authenticated], +) +async def record_statement_response( + body: StatementResponseSubmission, +) -> StatementResponseReceipt: + """Record acceptance of an account-level statement. + + Rejects any statement that requires a context: a per-launch declaration is bound + to the launch it belongs to and travels in the launch payload, so recording one + here would produce a receipt attached to nothing. + """ + return StatementResponseReceipt( + id=_RECEIPT_ID, + statement_id=_STATEMENT_ID, + responded_at=datetime(2026, 7, 31, 9, 0, tzinfo=UTC), + ) diff --git a/src/nc3_testing_platform/domains/statements/schemas.py b/src/nc3_testing_platform/domains/statements/schemas.py new file mode 100644 index 0000000..34cc84a --- /dev/null +++ b/src/nc3_testing_platform/domains/statements/schemas.py @@ -0,0 +1,83 @@ +"""Versioned declarations and their immutable receipts. + +One pair of shapes covers every declaration the platform needs: account-level +acceptance of Terms, AUP, and the privacy notice, and per-launch attestation of +permission to scan a target. Receipts follow the consent-record structure of +ISO/IEC 29184:2020 and ISO/IEC TS 27560:2023. + +Acceptance and attestation share the receipt shape but remain distinct acts, which +is what `response_kind` records — the user either agreed to something or asserted +a fact, and a compliance record has to be able to say which. + +MVP: Nothing in the v4.0 executable-test catalog is classified intrusive, so no +v4.0 launch requires a per-launch declaration. The launch field exists and is optional. + +A receipt never carries actor evidence on the wire. Identity, IP address, and user +agent are encrypted at rest, so that erasing the user renders them unreadable without +deleting the receipt. +""" + +from pydantic import BaseModel, Field + +from nc3_testing_platform.core.enums import StatementResponseKind +from nc3_testing_platform.core.schemas import BaseSchema, ResourceId, Timestamp + + +class StatementResponseSubmission(BaseModel): + """A client's answer to one statement, identified by key and version. + + The version is explicit, so the receipt records the exact text that was shown, + not whichever version happened to be current. + """ + + statement_key: str = Field( + description="Namespaced statement identifier, e.g. `scan_target_permission`.", + examples=["terms_and_conditions"], + ) + version: str = Field( + description="Exact version answered, as returned by `GET /statements`.", + examples=["2026-01-15"], + ) + + +class Statement(BaseSchema): + """One currently active versioned statement. + + Active means `effective_at` has passed and the statement is not retired. + """ + + id: ResourceId + statement_key: str + version: str + response_kind: StatementResponseKind + required_context_type: str | None = Field( + default=None, + description=( + "Null for an account-level statement. `scan_job` for a per-launch one, " + "whose response must be bound to the launch it belongs to." + ), + ) + content_hash: str = Field( + description="Hash of the exact text, so a receipt can prove what was shown." + ) + content_uri: str | None = None + effective_at: Timestamp + + +class StatementResponseReceipt(BaseSchema): + """Proof that a statement was answered. + + Immutable: a correction is a new statement version and a new response, never an + edit. Actor evidence is deliberately absent from this representation. + """ + + id: ResourceId + statement_id: ResourceId + responded_at: Timestamp + context_type: str | None = Field( + default=None, description="Null for an account-level response." + ) + context_id: ResourceId | None = Field( + default=None, + description="The bound resource — a ScanJob for a per-launch response.", + ) diff --git a/src/nc3_testing_platform/main.py b/src/nc3_testing_platform/main.py new file mode 100644 index 0000000..84d0af4 --- /dev/null +++ b/src/nc3_testing_platform/main.py @@ -0,0 +1,80 @@ +"""Application assembly. + +Domains are mounted under `/api/v1`. +""" + +from fastapi import APIRouter, FastAPI + +from nc3_testing_platform.core.errors import ( + configure_openapi, + register_exception_handlers, +) +from nc3_testing_platform.core.openapi import register_component_schemas +from nc3_testing_platform.domains.admin.router import router as admin_router +from nc3_testing_platform.domains.api_keys.router import router as api_keys_router +from nc3_testing_platform.domains.assets.router import public_feed_router +from nc3_testing_platform.domains.assets.router import router as assets_router +from nc3_testing_platform.domains.findings.router import router as findings_router +from nc3_testing_platform.domains.health.router import router as health_router +from nc3_testing_platform.domains.notifications.router import account_router +from nc3_testing_platform.domains.notifications.router import ( + router as notifications_router, +) +from nc3_testing_platform.domains.org.router import invitation_router +from nc3_testing_platform.domains.org.router import router as org_router +from nc3_testing_platform.domains.reports.router import router as reports_router +from nc3_testing_platform.domains.scans.router import router as scans_router +from nc3_testing_platform.domains.scans.schemas import ( + AssetScanLaunch, + FileScanLaunch, + GuestScanLaunch, + ScanEndEvent, + ScanHeartbeatEvent, + ScanJobEvent, + ScanTaskEvent, +) +from nc3_testing_platform.domains.schedules.router import router as schedules_router +from nc3_testing_platform.domains.statements.router import router as statements_router + +app = FastAPI( + title="NC3 Testing Platform API", + version="4.0.1", + summary="v4.0 backend MVP for the NC3 Testing Platform.", + openapi_url="/api/v1/openapi.json", + docs_url="/docs", +) + +register_exception_handlers(app) +configure_openapi(app) + +# Referenced only from handwritten schema — the launch variants from the +# media-type-dispatched request body on `POST /scans`, and the event payloads from +# the `text/event-stream` response — so FastAPI's own pass never sees them. +register_component_schemas( + app, + AssetScanLaunch, + GuestScanLaunch, + FileScanLaunch, + ScanTaskEvent, + ScanJobEvent, + ScanHeartbeatEvent, + ScanEndEvent, +) + +api_v1 = APIRouter(prefix="/api/v1") +api_v1.include_router(scans_router) +api_v1.include_router(assets_router) +api_v1.include_router(public_feed_router) +api_v1.include_router(schedules_router) +api_v1.include_router(findings_router) +api_v1.include_router(reports_router) +api_v1.include_router(notifications_router) +api_v1.include_router(account_router) +api_v1.include_router(org_router) +api_v1.include_router(invitation_router) +api_v1.include_router(api_keys_router) +api_v1.include_router(statements_router) +api_v1.include_router(admin_router) + +app.include_router(api_v1) +app.include_router(health_router) diff --git a/src/nc3_testing_platform/tools/__init__.py b/src/nc3_testing_platform/tools/__init__.py new file mode 100644 index 0000000..1b743e4 --- /dev/null +++ b/src/nc3_testing_platform/tools/__init__.py @@ -0,0 +1 @@ +"""Developer tooling entry points.""" diff --git a/src/nc3_testing_platform/tools/export_openapi.py b/src/nc3_testing_platform/tools/export_openapi.py new file mode 100644 index 0000000..b5fb27c --- /dev/null +++ b/src/nc3_testing_platform/tools/export_openapi.py @@ -0,0 +1,34 @@ +"""Exports the generated OpenAPI document to `api/openapi.json`. + +Run: `uv run export-openapi` or `make export-openapi`. +""" + +import json +import sys +from pathlib import Path +from typing import Any + +from nc3_testing_platform.main import app + +ROOT = Path(__file__).resolve().parents[3] +DEST = ROOT / "api" / "openapi.json" + + +def render(spec: dict[str, Any]) -> str: + """Serializes the document as written to `api/openapi.json`.""" + return json.dumps(spec, indent=2, ensure_ascii=False) + "\n" + + +def main() -> None: + """Entry point for the `export-openapi` project script.""" + spec = app.openapi() + DEST.write_text(render(spec), encoding="utf-8") + print( + f"Generated {DEST.relative_to(ROOT)} " + f"(openapi {spec['openapi']}, {len(spec['paths'])} paths)", + file=sys.stderr, + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_domain_name.py b/tests/test_domain_name.py new file mode 100644 index 0000000..2703bfe --- /dev/null +++ b/tests/test_domain_name.py @@ -0,0 +1,52 @@ +"""Tests the `DomainName` canonicalization applied at the contract boundary.""" + +import pytest +from pydantic import TypeAdapter, ValidationError + +from nc3_testing_platform.core.schemas import DomainName + +_parse = TypeAdapter(DomainName).validate_python + +CANONICAL = [ + ("example.com", "example.com"), + ("EXAMPLE.COM", "example.com"), + ("Example.Com", "example.com"), + ("example.com.", "example.com"), + ("example.com。", "example.com"), + ("example.com.", "example.com"), + ("example.com。", "example.com"), + ("bücher.de", "xn--bcher-kva.de"), + ("xn--bcher-kva.de", "xn--bcher-kva.de"), + ("sub.Example.com.", "sub.example.com"), +] + +REJECTED = [ + "example.com..", + "example..com", + "。", + ".", + "", + "localhost", + "example com.nl", + "a" * 64 + ".com", + ".".join(["a" * 63] * 4), +] + + +@pytest.mark.parametrize(("supplied", "expected"), CANONICAL) +def test_canonicalizes(supplied: str, expected: str) -> None: + """Canonical form is lowercase A-labels with no trailing dot, whichever full stop the client sent.""" + assert _parse(supplied) == expected + + +@pytest.mark.parametrize("supplied", REJECTED) +def test_rejects(supplied: str) -> None: + """Empty labels, single-label names, and over-long names are not domains.""" + with pytest.raises(ValidationError): + _parse(supplied) + + +@pytest.mark.parametrize("canonical", sorted({expected for _, expected in CANONICAL})) +def test_canonical_form_is_a_fixed_point(canonical: str) -> None: + """Reparsing a canonical value returns it unchanged, so asset uniqueness holds across repeated writes.""" + assert _parse(canonical) == canonical diff --git a/tests/test_error_contract.py b/tests/test_error_contract.py new file mode 100644 index 0000000..aa03415 --- /dev/null +++ b/tests/test_error_contract.py @@ -0,0 +1,59 @@ +"""Tests that every error leaves the application as an RFC 9457 problem detail.""" + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from nc3_testing_platform.core.errors import ( + PROBLEM_MEDIA_TYPE, + register_exception_handlers, +) + +_LEAKED_TEXT = "connection string nobody outside may read" + + +@pytest.fixture(scope="module") +def client() -> TestClient: + """An app carrying only the error handlers, one failing route, and one 404.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/unhandled") + async def unhandled() -> None: + """Fails the way a real handler fails.""" + raise RuntimeError(_LEAKED_TEXT) + + @app.get("/missing") + async def missing() -> None: + """Answers the way a declared 404 answers.""" + raise HTTPException(status_code=404, detail="No such thing.") + + return TestClient(app, raise_server_exceptions=False) + + +def test_unhandled_exception_answers_problem_json(client: TestClient) -> None: + """An exception no handler expected still satisfies the declared error contract.""" + response = client.get("/unhandled") + + assert response.status_code == 500 + assert response.headers["content-type"] == PROBLEM_MEDIA_TYPE + assert response.json() == { + "type": "about:blank", + "title": "Internal Server Error", + "status": 500, + "instance": "http://testserver/unhandled", + } + + +def test_unhandled_exception_leaks_nothing(client: TestClient) -> None: + """The exception text stays in the server log and never reaches the client.""" + assert _LEAKED_TEXT not in client.get("/unhandled").text + + +def test_http_exception_answers_problem_json(client: TestClient) -> None: + """A raised HTTPException carries its own detail through the same shape.""" + response = client.get("/missing") + + assert response.status_code == 404 + assert response.headers["content-type"] == PROBLEM_MEDIA_TYPE + assert response.json()["detail"] == "No such thing." diff --git a/tests/test_launch_dispatch.py b/tests/test_launch_dispatch.py new file mode 100644 index 0000000..02662a2 --- /dev/null +++ b/tests/test_launch_dispatch.py @@ -0,0 +1,39 @@ +"""Tests the media type that selects the `POST /scans` request schema.""" + +import pytest +from fastapi.testclient import TestClient + +from nc3_testing_platform.main import app + +JSON_SPELLINGS = [ + "application/json", + "application/JSON", + "Application/Json", + "application/json; charset=utf-8", + "APPLICATION/JSON; charset=UTF-8", +] + + +@pytest.fixture(scope="module") +def client() -> TestClient: + """The live mock application.""" + return TestClient(app) + + +@pytest.mark.parametrize("content_type", JSON_SPELLINGS) +def test_json_media_type_is_case_insensitive( + client: TestClient, content_type: str +) -> None: + """Every spelling selects the domain-launch schema, so the empty body fails validation rather than the media type.""" + response = client.post("/api/v1/scans", content=b"{}", headers={"content-type": content_type}) + + assert response.status_code == 422 + + +def test_unknown_media_type_is_rejected(client: TestClient) -> None: + """A media type that selects no launch schema answers 415.""" + response = client.post( + "/api/v1/scans", content=b"", headers={"content-type": "application/xml"} + ) + + assert response.status_code == 415 diff --git a/tests/test_openapi_export.py b/tests/test_openapi_export.py new file mode 100644 index 0000000..81818fa --- /dev/null +++ b/tests/test_openapi_export.py @@ -0,0 +1,132 @@ +"""Tests the OpenAPI document generation.""" + +from pathlib import Path +from typing import Any + +import pytest +from openapi_spec_validator import validate + +from nc3_testing_platform.main import app +from nc3_testing_platform.tools.export_openapi import DEST, render + +DECLARED_COMPONENTS = ( + "AssetScanLaunch", + "GuestScanLaunch", + "FileScanLaunch", + "ScanTaskEvent", + "ScanJobEvent", + "ScanHeartbeatEvent", + "ScanEndEvent", +) + +ANONYMOUS_OPERATIONS = { + ("/api/v1/scans", "post"), + ("/api/v1/scans/{scan_id}", "get"), + ("/api/v1/scans/{scan_id}/results", "get"), + ("/api/v1/scans/{scan_id}/events", "get"), + ("/api/v1/statements", "get"), + ("/api/v1/invitations/{token}", "get"), + ("/api/v1/feeds/{token}", "get"), +} + +_METHODS = ("get", "post", "put", "patch", "delete") + + +@pytest.fixture(scope="module") +def spec() -> dict[str, Any]: + """Returns the freshly generated document.""" + return app.openapi() + + +def test_spec_is_valid_openapi(spec: dict[str, Any]) -> None: + """The generated document passes OpenAPI schema validation.""" + validate(spec) + + +def test_spec_declares_openapi_3_1(spec: dict[str, Any]) -> None: + """The document declares OpenAPI 3.1.""" + assert spec["openapi"].startswith("3.1") + + +def test_committed_spec_matches_generated(spec: dict[str, Any]) -> None: + """`api/openapi.json` is byte-identical to what `export-openapi` writes.""" + assert Path(DEST).read_text(encoding="utf-8") == render(spec), ( + "api/openapi.json is stale — run 'make export-openapi'" + ) + + +def test_declared_components_are_published(spec: dict[str, Any]) -> None: + """Every schema registered in `main` reaches the contract.""" + published = spec["components"]["schemas"].keys() + assert set(DECLARED_COMPONENTS) <= set(published) + + +def _accepts_anonymous(operation: dict[str, Any]) -> bool: + """Reports whether an operation may be called without credentials. + + An absent `security` key and a requirement list containing an empty object both + mean anonymous; the second is OpenAPI's form for optional authentication. + """ + security = operation.get("security") + if security is None: + return True + return any(not requirement for requirement in security) + + +def test_anonymous_operations_are_exactly_the_documented_set( + spec: dict[str, Any], +) -> None: + """Only the seven operations named in api-design §1 accept an anonymous caller.""" + anonymous = { + (path, method) + for path, item in spec["paths"].items() + for method, operation in item.items() + if method in _METHODS and _accepts_anonymous(operation) + if path.startswith("/api/v1") + } + assert anonymous == ANONYMOUS_OPERATIONS + + +def test_claim_token_operations_declare_credential_and_anonymous_alternatives( + spec: dict[str, Any], +) -> None: + """An operation reading `claim_token` declares the credential schemes and the anonymous alternative. + + The routers attach the two declarations separately — the schemes through a dependency, the empty requirement through `openapi_extra` — so this asserts the pairing that neither attachment enforces alone. + """ + checked = set() + for path, item in spec["paths"].items(): + shared = item.get("parameters", []) + for method, operation in item.items(): + if method not in _METHODS: + continue + parameters = shared + operation.get("parameters", []) + if not any(p.get("name") == "claim_token" for p in parameters): + continue + checked.add((path, method)) + label = f"{method.upper()} {path}" + security = operation.get("security") or [] + schemes = {name for requirement in security for name in requirement} + assert {"OpenIdConnect", "ApiKey"} <= schemes, ( + f"{label} accepts claim_token but omits a credential alternative" + ) + assert any(not requirement for requirement in security), ( + f"{label} accepts claim_token but omits the anonymous alternative" + ) + assert checked, "no operation reads claim_token; retarget or remove this test" + + +def test_error_responses_use_problem_details(spec: dict[str, Any]) -> None: + """Error responses carry `application/problem+json`, per RFC 9457.""" + for path, item in spec["paths"].items(): + for method, operation in item.items(): + if method not in _METHODS: + continue + for status, response in operation["responses"].items(): + if not status.startswith(("4", "5")): + continue + content = response.get("content") + assert content, f"{method.upper()} {path} {status} declares no body" + assert "application/problem+json" in content, ( + f"{method.upper()} {path} {status} is not problem+json" + ) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..0f8c7d8 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1289 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "detect-installer" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/ce/6897d812825e9d4c53e3c7112726e800cc5231b013b2223bf64f653ff362/detect_installer-0.1.0.tar.gz", hash = "sha256:00ad7ba0a36e3cf7d08a40d3643011746dbc112597c7d475cc91c416710ca4e7", size = 3049, upload-time = "2026-02-23T10:40:22.567Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/34/8cc73273414405086c58852916e4031812a6a30fe04c057e37ad99397b7f/detect_installer-0.1.0-py3-none-any.whl", hash = "sha256:034fb20fd665c36e6ba52b8821525ea07fb4f7f938cac459df889fb33801528a", size = 4539, upload-time = "2026-02-23T10:40:23.807Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.139.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "fastar" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "pydantic-extra-types" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/eb/3b534c6f8e157f9ddbf2a153512307c886cad0b258739c200dd8ff8c4452/fastapi_cli-0.0.32.tar.gz", hash = "sha256:38024d2345275e1b37ce8848727a580d84901b570e96b3256d9d36a9a5039424", size = 26636, upload-time = "2026-07-16T12:16:58.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/53/56ae5ae17bb0a5d89d1d31e5320eb1865553ebbfbde91cdc4c221245f2a8/fastapi_cli-0.0.32-py3-none-any.whl", hash = "sha256:8dcc286fa32f01bbd3f65dd09cfd5a2540ed5f2230b77db7fd30978d6165f3c4", size = 14670, upload-time = "2026-07-16T12:16:57.297Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "fastapi-cloud-cli" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cloud-cli" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "detect-installer" }, + { name = "fastar" }, + { name = "httpx" }, + { name = "pydantic", extra = ["email"] }, + { name = "rich-toolkit" }, + { name = "rignore" }, + { name = "sentry-sdk" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/f2/36bfe990baa656de89a2b98a77a15dcd018474f7245c8e4a10cada0553c5/fastapi_cloud_cli-0.22.2.tar.gz", hash = "sha256:7ec78c1fed58f578af5eb1fb54ec4b456eba4dc1eaca1c3a93cf499a7cbc7ab3", size = 94480, upload-time = "2026-07-14T09:27:01.349Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/25/e01631a63a5213fc783e5b84e8a27eca800f21549aa414644a0a29181045/fastapi_cloud_cli-0.22.2-py3-none-any.whl", hash = "sha256:37c6b05adb94a4c9f59916a559d041c565db36b5f3dddeed420bf0a51182c363", size = 77744, upload-time = "2026-07-14T09:27:02.571Z" }, +] + +[[package]] +name = "fastar" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/0f/0aeb3fc50046617702acc0078b277b58367fd62eb727b9ec733ae0e8bbcc/fastar-0.11.0.tar.gz", hash = "sha256:aa7f100f7313c03fdb20f1385927ba95671071ba308ad0c1763fef295e1895ce", size = 70238, upload-time = "2026-04-13T17:11:17.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/d6/3be260037e86fb694e88d47f583bac3a0188c99cee1a6b257ac26cb6b53c/fastar-0.11.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:33f544b08b4541b678e53749b4552a44720d96761fb79c172b005b1089c443ed", size = 707975, upload-time = "2026-04-13T17:09:58.866Z" }, + { url = "https://files.pythonhosted.org/packages/e1/cd/7867aefb1784662554a335f2952c75a50f0c70585ed0d2210d6cc15e5627/fastar-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c1c792447e4a642745f347ff9847c52af39633071c57ee67ed53c157fc3506", size = 628460, upload-time = "2026-04-13T17:09:43.776Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2b/d11d84bdd5e0e377771b955755771e3460b290da5809cb78c1b735ee2228/fastar-0.11.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:881247e6b6eaea59fc6569f9b61447aa6b9fc2ee864e048b4643d69c52745805", size = 863054, upload-time = "2026-04-13T17:09:13.048Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/d3f428b318fa940b1b6e785b8d54fc895dfb5d5b945ef8d5442ffa904fb2/fastar-0.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:863b7929845c9fec92ef6c8d59579cf46af5136655e5342f8df5cebe46cab06c", size = 760247, upload-time = "2026-04-13T17:07:57.396Z" }, + { url = "https://files.pythonhosted.org/packages/9e/04/03949aee82aabb8ede06ac5a4a5579ffaf98a8fe59ce958494508ff15513/fastar-0.11.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:96b4a57df12bf3211662627a3ea29d62ecb314a2434a0d0843f9fc23e47536e5", size = 756512, upload-time = "2026-04-13T17:08:12.415Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/2ca1ae0a3828ca51047962d932b80daca2522db73e8cb9d040cb6ebe28d5/fastar-0.11.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceef1c2c4df7b7b8ebd3f5d718bbf457b9bbdf25ce0bd07870211ec4fbd9aff4", size = 922183, upload-time = "2026-04-13T17:08:27.187Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/7fe808b1f73a68e686f25434f538c6dc10ef4dfb3db0ace22cd861744bf8/fastar-0.11.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8e545918441910a779659d4759ad0eef349e935fbdb4668a666d3681567eb05", size = 816394, upload-time = "2026-04-13T17:08:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/1f/17/07d086080f8a83b8d7966955e29bcdbd6a060f5bd949dc9d5abd3658cead/fastar-0.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28095bb8f821e85fc2764e1a55f03e5e2876dee2abe7cd0ee9420d929905d643", size = 818983, upload-time = "2026-04-13T17:09:28.46Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/2c4edf0910af2e814ff6d65b77a91196d472ca8a9fb2033bd983f6856caa/fastar-0.11.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0fafb95ecbe70f666a5e9b35dd63974ccdc9bb3d99ccdbd4014a823ec3e659b5", size = 884689, upload-time = "2026-04-13T17:08:42.763Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/04fdcbd6558e60de4ced3b55230fac47675d181252582b2fcec3c74608e5/fastar-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:af48fed039b94016629dcdad1c95c90c486326dd068de2b0a4df419ee09b6821", size = 970677, upload-time = "2026-04-13T17:10:15.124Z" }, + { url = "https://files.pythonhosted.org/packages/df/b3/2b860a9658550167dbd5824c85e88d0b4b912bf493e42a6322544d6e483d/fastar-0.11.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:74cd96163f39b8638ab4e8d49708ca887959672a22871d8170d01f067319533b", size = 1034026, upload-time = "2026-04-13T17:10:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/b7/9b/fa42ea1188b144bac4b1b60753dfd449974a4d5eda132029ee7711569f94/fastar-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4e8b993cb5613bab495ed482810bedc0986633fcb9a3b55c37ec88e0d6714f6a", size = 1071147, upload-time = "2026-04-13T17:10:48.833Z" }, + { url = "https://files.pythonhosted.org/packages/95/c8/d2e501556dca9f1fbc9246111a31792fb49ad908fa4927f34938a97a3604/fastar-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfe39d91fc28e37e06162d94afe01050220edb7df554acb5b702b5503e564816", size = 1028377, upload-time = "2026-04-13T17:11:06.374Z" }, + { url = "https://files.pythonhosted.org/packages/db/33/5f11f23eca0a569cd052507bc45dda2e5468697f8665728d25be44120f7d/fastar-0.11.0-cp313-cp313-win32.whl", hash = "sha256:c5f63d4d99ff4bfb37c659982ec413358bdee747005348756cc50a04d412d989", size = 454089, upload-time = "2026-04-13T17:11:46.821Z" }, + { url = "https://files.pythonhosted.org/packages/da/2f/35ff03c939cba7a255a9132367873fec6c355fd06a7f84fedcbaf4c8129f/fastar-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8690ed1928d31ded3ada308e1086525fb3871f5fa81e1b69601a3f7774004583", size = 486312, upload-time = "2026-04-13T17:11:32.86Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/ee9246cbfcbfd4144558f35e7e9a306ffe0a7564730a5188c45f21d2dab8/fastar-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:d977ded9d98a0719a305e0a4d5ee811f1d3e856d853a50acb8ae833c3cd6d5d2", size = 461975, upload-time = "2026-04-13T17:11:22.589Z" }, + { url = "https://files.pythonhosted.org/packages/7a/cd/3644c48ecac456f928c12d47ec3bed36c36555b17c3859856f1ff860265d/fastar-0.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:71375bd6f03c2a43eb47bd949ea38ff45434917f9cdac79675c5b9f60de4fa73", size = 707860, upload-time = "2026-04-13T17:10:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/dee04476ae3626b2b040a60ad84628f77e1ffd8444232f2426b0ca1e0d7e/fastar-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eddfd9cab16e19ae247fe44bf992cb403ccfe27d3931d6de29a4695d95ad386c", size = 628216, upload-time = "2026-04-13T17:09:45.355Z" }, + { url = "https://files.pythonhosted.org/packages/dc/5e/9395c7353d079cb4f5be0f7982ce0dc9f2e7dec5fd175eef466729d6023a/fastar-0.11.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c371f1d4386c699018bb64eb2fa785feacf32785559049d2bb72fe4af023f53", size = 864378, upload-time = "2026-04-13T17:09:14.611Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/1e4f67148223ff219612b6281a6000357abbcc2417964fa5c83f11d68fce/fastar-0.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cad7fa41e3e66554387481c1a09365e4638becd322904932674159d5f4046728", size = 760921, upload-time = "2026-04-13T17:07:59.138Z" }, + { url = "https://files.pythonhosted.org/packages/0f/82/09d11fb6d12f17993ffaf32ffd30c3c121a11e2966e84f19fb6f66430118/fastar-0.11.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf36652fa71b83761717c9899b98732498f8a2cb6327ff16bbf07f6be85c3437", size = 757012, upload-time = "2026-04-13T17:08:14.186Z" }, + { url = "https://files.pythonhosted.org/packages/52/1f/5aeeacc4cb65615e2c9292cd9c5b0cd6fb6d2e6ee472ca6adc6c1b1b22ef/fastar-0.11.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f68ff8c17833053da4841720e95edde80ce45bb994b6b7d51418dddaac70ee47", size = 924510, upload-time = "2026-04-13T17:08:28.741Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1a/1e5bdabbeaf2e856928956292609f2ff6a650f94480fb8afaca30229e483/fastar-0.11.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4563ed37a12ea1cdc398af8571258d24b988bf342b7b3bf5451bd5891243280c", size = 816602, upload-time = "2026-04-13T17:08:59.461Z" }, + { url = "https://files.pythonhosted.org/packages/87/24/f960147910da3bed41a3adfcb026e17d5f50f4cf467a3324237a7088f61a/fastar-0.11.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cee63c9875cba3b70dc44338c560facc5d6e763047dcc4a30501f9a68cf5f890", size = 819452, upload-time = "2026-04-13T17:09:29.926Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f4/3e77d7901d5707fd7f8a352e153c8ae09ea974e6fabad0b7c4eb9944b8d4/fastar-0.11.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:bd76bfffae6d0a91f4ac4a612f721e7aec108db97dccdd120ae063cd66959f27", size = 885254, upload-time = "2026-04-13T17:08:44.285Z" }, + { url = "https://files.pythonhosted.org/packages/47/01/1585edd5ec47782ae93cd94edf05828e0ab02ef00aec00aea4194a600464/fastar-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f5b707501ec01c1bc0518f741f01d322e50c9adc19a451aa24f67a2316e9397", size = 971496, upload-time = "2026-04-13T17:10:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e9/6874c9d1236ded565a0bed54b320ac9f165f287b1d89490fb70f9f323c81/fastar-0.11.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:37c0b5a88a657839aad98b0a6c9e4ac4c2c15d6b49c44ee3935c6b08e9d3e479", size = 1034685, upload-time = "2026-04-13T17:10:34.063Z" }, + { url = "https://files.pythonhosted.org/packages/14/d8/4ab20613ce2983427aee958e39be878dba874aa227c530a845e32429c4f6/fastar-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6c55f536c62a6efb180c1af0d5182948bff576bbfe6276e8e1359c9c7d2215d8", size = 1072675, upload-time = "2026-04-13T17:10:50.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/5ac3b7c20ce4b08f011dd2b979f96caabe64f9b10b157f211ea91bdfadca/fastar-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3082eeca59e189b9039335862f4c2780c0c8871d656bfdf559db4414a105b251", size = 1029330, upload-time = "2026-04-13T17:11:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e7/37cd6a1d4e288292170b64e19d79ecce2a7de8bb76790323399a2abc4619/fastar-0.11.0-cp314-cp314-win32.whl", hash = "sha256:b201a0a4e29f9fec2a177e13154b8725ec65ab9f83bd6415483efaa2aa18344b", size = 453940, upload-time = "2026-04-13T17:11:48.713Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1c/795c878b1ee29d79021cf8ed81f18f2b25ccde58453b0d34b9bdc7e025ea/fastar-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:868fddb26072a43e870a8819134b9f80ee602931be5a76e6fb873e04da343637", size = 486334, upload-time = "2026-04-13T17:11:34.882Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a4/113f104301df8bddcc0b3775b611a30cb7610baa3add933c7ccac9386467/fastar-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:3db39c9cc42abb0c780a26b299f24dfbc8be455985e969e15336d70d7b2f833b", size = 461534, upload-time = "2026-04-13T17:11:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a6/5c5f2c2c8e0c63e56a5636ebc7721589c889e94c0092cec7eb28ae7207e6/fastar-0.11.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:49c3299dec5e125e7ebaa27545714da9c7391777366015427e0ae62d548b442b", size = 707156, upload-time = "2026-04-13T17:10:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/df/f7/982c01b61f0fc135ad2b16d01e6d0ee53cf8791e68827f5f7c5a65b2e5b1/fastar-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3328ed1ed56d31f5198350b17dd60449b8d6b9d47abb4688bab6aef4450a165b", size = 627032, upload-time = "2026-04-13T17:09:46.978Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c3/38f1dac77ae0c71c37b176277c96d830796b8ce2fe69705f917829b53829/fastar-0.11.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd3eca3bbfec84a614bcb4143b4ad4f784d0895babc26cfc88436af88ca23c7a", size = 864403, upload-time = "2026-04-13T17:09:16.58Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/e69c363bdb3e5a5848e937b662b5469581ee6682c51bc1c0556494773929/fastar-0.11.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff86a967acb0d621dd24063dda090daa67bf4993b9570e97fe156de88a9006ca", size = 759480, upload-time = "2026-04-13T17:08:00.599Z" }, + { url = "https://files.pythonhosted.org/packages/3b/29/4d8737590c2a6357d614d7cc7288e8f68e7e449680b8922997cc4349e65e/fastar-0.11.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86eaf7c0e985d93a7734168be2fb232b2a8cca53e41431c2782d7c12b12c03b1", size = 756219, upload-time = "2026-04-13T17:08:15.699Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ec/400de7b3b7d48801908f19cf5462177104395799472671b3e8152b2b04ca/fastar-0.11.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91f07b0b8eb67e2f177733a1f884edad7dfb9f8977ffef15927b20cb9604027d", size = 923669, upload-time = "2026-04-13T17:08:30.574Z" }, + { url = "https://files.pythonhosted.org/packages/5d/01/8926c53da923fed7ab4b96e7fbf7f73b663beb4f02095b654d6fab46f9ad/fastar-0.11.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f85c896885eb4abf1a635d54dea22cac6ae48d04fc2ea26ae652fcf1febe1220", size = 815729, upload-time = "2026-04-13T17:09:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/89/f0/5fef4c7946e352651b504b1a4235dac3505e7cfd24020788ab50552e84bf/fastar-0.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:075c07095c8de4b774ba8f28b9c0a02b1a2cd254da50cbe464dd3bb2432e9158", size = 819812, upload-time = "2026-04-13T17:09:31.907Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c8/0ebc3298b4a45e7bddc50b169ae6a6f5b80c939394d4befe6e60de535ee7/fastar-0.11.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:07f028933820c65750baf3383b807ecce1cd9385cf00ce192b79d263ad6b856c", size = 884074, upload-time = "2026-04-13T17:08:45.802Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9f/7baa4cdff8d6fbca41fa5c764b48a941fed8a9ec6c4cc92de65895a28299/fastar-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:039f875efa0f01fa43c20bf4e2fc7305489c61d0ac76eda991acfba7820a0e63", size = 969450, upload-time = "2026-04-13T17:10:18.667Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dc/1ebbfb58a47056ba866494f19efbcdd2ba2897096b94f36e796594b4d05b/fastar-0.11.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:fff12452a9a5c6814a012445f26365541cc3d99dcca61f09762e6a389f7a32ea", size = 1033775, upload-time = "2026-04-13T17:10:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5f/ce4e3914066f08c99eb8c32952cc07c1a013e81b1db1b0f598130bf6b974/fastar-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2bf733e09f942b6fa876efe30a90508d1f4caef5630c00fb2a84fba355873712", size = 1072158, upload-time = "2026-04-13T17:10:52.497Z" }, + { url = "https://files.pythonhosted.org/packages/03/2a/6bca72992c84151c387cc6558f3867f5ebe5fb3684ee6fa9b76280ba4b8e/fastar-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d1531fa848fdd3677d2dce0a4b436ea64d9ae38fb8babe2ddbc180dd153cb7a3", size = 1028577, upload-time = "2026-04-13T17:11:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/83/18/7a7c15657a3da5569b26fc51cde6a80f8d84cb54b3b1aea6d74a103db4ad/fastar-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:5744551bc67c6fc6581cbd0e34a0fd6e2cd0bd30b43e94b1c3119cf35064b162", size = 453601, upload-time = "2026-04-13T17:11:53.726Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/331b59a6de279f3ad75c10c02c40a12f21d64a437d9c3d6f1af2dcbd7a76/fastar-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f4ce44e3b56c47cf38244b98d29f269b259740a580c47a2552efa5b96a5458fb", size = 486436, upload-time = "2026-04-13T17:11:40.089Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fd/5390ec4f49100f3ecb9968a392f9e6d039f1e3fe0ecd28443716ff01e589/fastar-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:76c1359314355eafbc6989f20fb1ad565a3d10200117923b9da765a17e2f6f11", size = 461049, upload-time = "2026-04-13T17:11:25.918Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-path" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597, upload-time = "2026-05-19T20:45:00.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077, upload-time = "2026-05-19T20:44:59.225Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "lazy-object-proxy" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681, upload-time = "2025-08-22T13:50:06.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/26/b74c791008841f8ad896c7f293415136c66cc27e7c7577de4ee68040c110/lazy_object_proxy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:86fd61cb2ba249b9f436d789d1356deae69ad3231dc3c0f17293ac535162672e", size = 26745, upload-time = "2025-08-22T13:42:44.982Z" }, + { url = "https://files.pythonhosted.org/packages/9b/52/641870d309e5d1fb1ea7d462a818ca727e43bfa431d8c34b173eb090348c/lazy_object_proxy-1.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81d1852fb30fab81696f93db1b1e55a5d1ff7940838191062f5f56987d5fcc3e", size = 71537, upload-time = "2025-08-22T13:42:46.141Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/919118e99d51c5e76e8bf5a27df406884921c0acf2c7b8a3b38d847ab3e9/lazy_object_proxy-1.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9045646d83f6c2664c1330904b245ae2371b5c57a3195e4028aedc9f999655", size = 71141, upload-time = "2025-08-22T13:42:47.375Z" }, + { url = "https://files.pythonhosted.org/packages/e5/47/1d20e626567b41de085cf4d4fb3661a56c159feaa73c825917b3b4d4f806/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67f07ab742f1adfb3966c40f630baaa7902be4222a17941f3d85fd1dae5565ff", size = 69449, upload-time = "2025-08-22T13:42:48.49Z" }, + { url = "https://files.pythonhosted.org/packages/58/8d/25c20ff1a1a8426d9af2d0b6f29f6388005fc8cd10d6ee71f48bff86fdd0/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ba769017b944fcacbf6a80c18b2761a1795b03f8899acdad1f1c39db4409be", size = 70744, upload-time = "2025-08-22T13:42:49.608Z" }, + { url = "https://files.pythonhosted.org/packages/c0/67/8ec9abe15c4f8a4bcc6e65160a2c667240d025cbb6591b879bea55625263/lazy_object_proxy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7b22c2bbfb155706b928ac4d74c1a63ac8552a55ba7fff4445155523ea4067e1", size = 26568, upload-time = "2025-08-22T13:42:57.719Z" }, + { url = "https://files.pythonhosted.org/packages/23/12/cd2235463f3469fd6c62d41d92b7f120e8134f76e52421413a0ad16d493e/lazy_object_proxy-1.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4a79b909aa16bde8ae606f06e6bbc9d3219d2e57fb3e0076e17879072b742c65", size = 27391, upload-time = "2025-08-22T13:42:50.62Z" }, + { url = "https://files.pythonhosted.org/packages/60/9e/f1c53e39bbebad2e8609c67d0830cc275f694d0ea23d78e8f6db526c12d3/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:338ab2f132276203e404951205fe80c3fd59429b3a724e7b662b2eb539bb1be9", size = 80552, upload-time = "2025-08-22T13:42:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/6c513693448dcb317d9d8c91d91f47addc09553613379e504435b4cc8b3e/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c40b3c9faee2e32bfce0df4ae63f4e73529766893258eca78548bac801c8f66", size = 82857, upload-time = "2025-08-22T13:42:53.225Z" }, + { url = "https://files.pythonhosted.org/packages/12/1c/d9c4aaa4c75da11eb7c22c43d7c90a53b4fca0e27784a5ab207768debea7/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:717484c309df78cedf48396e420fa57fc8a2b1f06ea889df7248fdd156e58847", size = 80833, upload-time = "2025-08-22T13:42:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ae/29117275aac7d7d78ae4f5a4787f36ff33262499d486ac0bf3e0b97889f6/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b7ea5ea1ffe15059eb44bcbcb258f97bcb40e139b88152c40d07b1a1dfc9ac", size = 79516, upload-time = "2025-08-22T13:42:55.812Z" }, + { url = "https://files.pythonhosted.org/packages/19/40/b4e48b2c38c69392ae702ae7afa7b6551e0ca5d38263198b7c79de8b3bdf/lazy_object_proxy-1.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:08c465fb5cd23527512f9bd7b4c7ba6cec33e28aad36fbbe46bf7b858f9f3f7f", size = 27656, upload-time = "2025-08-22T13:42:56.793Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3a/277857b51ae419a1574557c0b12e0d06bf327b758ba94cafc664cb1e2f66/lazy_object_proxy-1.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9defba70ab943f1df98a656247966d7729da2fe9c2d5d85346464bf320820a3", size = 26582, upload-time = "2025-08-22T13:49:49.366Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/c5e0fa43535bb9c87880e0ba037cdb1c50e01850b0831e80eb4f4762f270/lazy_object_proxy-1.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6763941dbf97eea6b90f5b06eb4da9418cc088fce0e3883f5816090f9afcde4a", size = 71059, upload-time = "2025-08-22T13:49:50.488Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/7dcad19c685963c652624702f1a968ff10220b16bfcc442257038216bf55/lazy_object_proxy-1.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdc70d81235fc586b9e3d1aeef7d1553259b62ecaae9db2167a5d2550dcc391a", size = 71034, upload-time = "2025-08-22T13:49:54.224Z" }, + { url = "https://files.pythonhosted.org/packages/12/ac/34cbfb433a10e28c7fd830f91c5a348462ba748413cbb950c7f259e67aa7/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0a83c6f7a6b2bfc11ef3ed67f8cbe99f8ff500b05655d8e7df9aab993a6abc95", size = 69529, upload-time = "2025-08-22T13:49:55.29Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6a/11ad7e349307c3ca4c0175db7a77d60ce42a41c60bcb11800aabd6a8acb8/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:256262384ebd2a77b023ad02fbcc9326282bcfd16484d5531154b02bc304f4c5", size = 70391, upload-time = "2025-08-22T13:49:56.35Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/9b410ed8fbc6e79c1ee8b13f8777a80137d4bc189caf2c6202358e66192c/lazy_object_proxy-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7601ec171c7e8584f8ff3f4e440aa2eebf93e854f04639263875b8c2971f819f", size = 26988, upload-time = "2025-08-22T13:49:57.302Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "nc3-testing-platform" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi", extra = ["standard"] }, + { name = "idna" }, + { name = "pydantic", extra = ["email"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx2" }, + { name = "openapi-spec-validator" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", extras = ["standard"], specifier = ">=0.139.2" }, + { name = "idna", specifier = ">=3.18" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.13.4" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx2", specifier = ">=2.9.1" }, + { name = "openapi-spec-validator", specifier = ">=0.9.0" }, + { name = "pyright", specifier = ">=1.1.411" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = ">=0.16.0" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "openapi-schema-validator" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jsonschema-specifications" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "referencing" }, + { name = "rfc3339-validator" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/e8/ab3f27dbca54ec645f7fab714b640907d5d36c2ebb07e87eebd30bd5c81b/openapi_schema_validator-0.9.0.tar.gz", hash = "sha256:b72db64315b89d21834cd3ffef37e3e6893bc876327be2d366e8424b1029afd3", size = 24686, upload-time = "2026-04-27T17:31:27.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/c0/5467967d95378b2cfce312e09cbd0c9ab64354a0922379b734f793edd04f/openapi_schema_validator-0.9.0-py3-none-any.whl", hash = "sha256:faa3bbe7c3aa8ca2087ad83f709dc3b7d920283153a570c03e24ea182558aa25", size = 19980, upload-time = "2026-04-27T17:31:25.965Z" }, +] + +[[package]] +name = "openapi-spec-validator" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jsonschema-path" }, + { name = "lazy-object-proxy" }, + { name = "openapi-schema-validator" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/d2/640b5149cd5688bc0ad1fdbb4df6a2f7b84a093c8d787c27d566132f8b8b/openapi_spec_validator-0.9.0.tar.gz", hash = "sha256:6d648cff6490ebb799dcfe273792f2941c050158854c721f086599d845da78b8", size = 1756839, upload-time = "2026-05-20T09:23:18.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/d8/321ff889330acca2e3097f3d4f80a40bcc41b6d34d302978ab32c449520b/openapi_spec_validator-0.9.0-py3-none-any.whl", hash = "sha256:222fecffc7714f6d0a6ad62c0e4b66cc2b7dbfafb7b93acfc6c308abbdb51af8", size = 50328, upload-time = "2026-05-20T09:23:17.017Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathable" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970, upload-time = "2026-05-19T18:15:11.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" }, +] + +[[package]] +name = "pydantic-settings" +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/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/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]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +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-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +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 = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-toolkit" +version = "0.20.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/3a/a258c2fbc6c6bdf428611388f5698ba5d57ffdf0755e1cab474d9cc47813/rich_toolkit-0.20.3.tar.gz", hash = "sha256:223dd2cfba325ed55e94933b9e53f3aca13e9fdf76622bd564c18109a2273c1b", size = 205355, upload-time = "2026-07-13T14:38:06.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/ce/639d0d0ce3d25c5edbd1afecd308bb35dc04883a45ac9f0855c8aee4e919/rich_toolkit-0.20.3-py3-none-any.whl", hash = "sha256:419aa87516d5f3849cca553c6dcf707c02a36d508fcf996946606725d34a3002", size = 36195, upload-time = "2026-07-13T14:38:05.687Z" }, +] + +[[package]] +name = "rignore" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/77/6ba90ab4a538d3ec244329c57c4d26a78c8313ea6fa72c8768d46f11c1c9/rignore-0.8.0.tar.gz", hash = "sha256:2e5ad6b19834f04a877d26fe863fd77ed851ed4019fdca097fb1b744311e3562", size = 55358, upload-time = "2026-07-17T19:01:21.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/76/fc272f7a61bec353d321f04b6d4d6cf7cb1fe646e0e1bedd1abf187699bd/rignore-0.8.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:178d7a82b6f1dd0378efda2a69252f73240221ea727529ed67be788ad551cccf", size = 847049, upload-time = "2026-07-17T18:58:54.123Z" }, + { url = "https://files.pythonhosted.org/packages/a1/06/99ce87ef61c86670b3ffa1be6131bc73318a541c2d9d00edbfe00fe27b1f/rignore-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0cefccd353ab5f7436457c420a4058daba873552344eb731b124c6d25110fef9", size = 815664, upload-time = "2026-07-17T18:58:55.422Z" }, + { url = "https://files.pythonhosted.org/packages/1c/88/ecb7451631e493ce8d41d0cfa287a9aca891ca2b738ded4dd8be83cf1476/rignore-0.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd72c6eeec68a3354582e11168c702c55ff543af7a3e8b3f47bed63334d0a330", size = 884344, upload-time = "2026-07-17T18:58:56.82Z" }, + { url = "https://files.pythonhosted.org/packages/e8/fe/24fe7e5f36c0ea0043017ee92ec6bbb980a6ca356e5fbe0ab3320a6c5482/rignore-0.8.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3e0f17f611ab32a505f32f3d6b6a08cc0e9ef4c596df07a8ebbc99225d07ca8b", size = 857024, upload-time = "2026-07-17T18:58:58.444Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e4/267814f4d4a96208f2d676d3e070b73d386a58dd45bad2bc2fd7d9babb27/rignore-0.8.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:828630c1710e3d21f79dd90b1939b1b8da4a49bdab857ce7fc11b58dd5ce509e", size = 1133041, upload-time = "2026-07-17T18:59:00.036Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b6/e6da8ed3ee26134bb1d08a5d181012c16a4bfeb4dfa5f461edf3d19dff95/rignore-0.8.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b074ea1fece686da847d2dc7a7f452af9f7a82e7bc9181f196104f1c0df4ebd", size = 912935, upload-time = "2026-07-17T18:59:01.333Z" }, + { url = "https://files.pythonhosted.org/packages/ed/91/4785a1673aa92b34bd2625b2c836debadfee4f6e394b273c86060f68b05b/rignore-0.8.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cb7dfc95cae2deac47e5f75b6d8df041351664907e412ac39c3af44dae47ce0", size = 927344, upload-time = "2026-07-17T18:59:02.744Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8e/07d11d91c73f3723024ba5a1034818f6232f3c27c7bf194f2bc1eabb449c/rignore-0.8.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:80d3e468c2cd24be93eca4953ca7ea73dfaacf97ba674c54c61c8aad877d2bc3", size = 892736, upload-time = "2026-07-17T18:59:04.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/85/97f07d4c6737e6ae7dbef37b7eda5b7de1442167dc696483f7e002e2c8a9/rignore-0.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3ac6b6ce80d24015055d07b47a5157b8cb9b207bf9d4963f401b2c08e0263eb", size = 962660, upload-time = "2026-07-17T18:59:05.443Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6f/524ba5a14d2e297edc3ea5baece5eec38bdb8a40e717b86aecaa3b84aa48/rignore-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f6200325937f8c6a24dab351e259fce6ea8fd744888118791074796f4041cb0a", size = 1060679, upload-time = "2026-07-17T18:59:06.867Z" }, + { url = "https://files.pythonhosted.org/packages/a6/84/699fdb9a0380ff1dc4910d7cb5d730dd823ba7b525d1c92cdb57023545cd/rignore-0.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:51b33030b83526e4284f2e115e04ff80f0ed8d992cd5bf389c275aaac534b829", size = 1132220, upload-time = "2026-07-17T18:59:08.211Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ac/c840f5992ece10f19f27607603b23864dd7a5fad6acb90eb43c52f02879e/rignore-0.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:48aa8acf2217d77cb4784fd5f9a95aaa73a85b9a337b7511679b1bc73f64b7d9", size = 1139449, upload-time = "2026-07-17T18:59:09.752Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1b/3f6f2a0a1b23e639f637394821c68bfb3a435349ed5ecd562af08c9a3e4f/rignore-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bc453de8490c76ab3905a18116d0f6555ee94540e1e69cca49e6675e9ca05888", size = 1138480, upload-time = "2026-07-17T18:59:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/98/b2/f5478b3850ad894f1480b36028b54868ba9c0069b7b13f4d78ff0a0391f7/rignore-0.8.0-cp313-cp313-win32.whl", hash = "sha256:0f03e964e7845583b1c344098d6e1b178e0e53ba7e496c8f148966b74dd2e5a9", size = 637473, upload-time = "2026-07-17T18:59:12.748Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3e/53ab47642c45222e4d47877d62e80ea79c0cf0b989a1b89f04244f14d6a4/rignore-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:71a00835b08987c86d6b7c14661a7a628c868bc649e2afbeb4962167561fbd11", size = 727646, upload-time = "2026-07-17T18:59:14.135Z" }, + { url = "https://files.pythonhosted.org/packages/0d/33/9793162cbe185c05869b41c029c341dca659008b1e55aefd0fd6f8cf67e3/rignore-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:28d20c22758b636936d8ae2a684b4ed2c64deea30410ca203c72cdfb4bbde0f1", size = 664864, upload-time = "2026-07-17T18:59:15.534Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/b7d365a5d103e527b3d23d11f19ab91ff5813a2509a236d8f56f608f03ff/rignore-0.8.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:f7cd98185bb89d11676454df1617f00ca59473fe2c86471c7c7d8c97ed6359f6", size = 846928, upload-time = "2026-07-17T18:59:16.862Z" }, + { url = "https://files.pythonhosted.org/packages/bc/4a/736d51756de8557be4ef2837d4c2eb6e3934887f1c04eb6ec1d0198b708d/rignore-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4101def5fdd1459ba107710382c18916c9981f961cad5196533ee4b06295f05b", size = 817297, upload-time = "2026-07-17T18:59:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/3799fa414d51779b4ffb71fb8fb861f886a592127cd28f88d81b1681dc4a/rignore-0.8.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a67b7f4485dbda470981c8ea7cdf270f2f9139169e86c8b57b9bb4649e79b7f6", size = 885129, upload-time = "2026-07-17T18:59:19.807Z" }, + { url = "https://files.pythonhosted.org/packages/c9/3e/0d1771740b5ea32828e563fe8044e8ae6fbdf7edb70e69dc116b704a751e/rignore-0.8.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:78d66ae2e95504743beccc7310aa0476dce27283e0341a22348f6528083dfbfe", size = 856976, upload-time = "2026-07-17T18:59:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/48/a1/4edeb2f03ef4cf74ca3518225415a4b036201cde43a6ca1a35a501481367/rignore-0.8.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa936504e875b5a3782d454a4121cb6912e4fb295bc0954a14e5a07b26aa27f8", size = 1136148, upload-time = "2026-07-17T18:59:22.565Z" }, + { url = "https://files.pythonhosted.org/packages/34/65/3f1cc51677e6225248ab1c374e4ed5db057c7d1bae538af3305ccb18ed54/rignore-0.8.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84a3dfd3edc7fe00dd368944f2305f13ddabfb08658e35f2f87ae51dc3e760cc", size = 915273, upload-time = "2026-07-17T18:59:24.079Z" }, + { url = "https://files.pythonhosted.org/packages/70/c3/90e3af9f983a7ca9e1b446edf7d9c07382232f74120a1699efea526776a2/rignore-0.8.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77ae349a8fd54eb35cc804360b41ec71a55a574502054f574911204de193c067", size = 929366, upload-time = "2026-07-17T18:59:25.479Z" }, + { url = "https://files.pythonhosted.org/packages/8a/c3/02e3aa7e4ccef1c5153fff3951efa5a84d4f5dbdc81f459e176083ca7443/rignore-0.8.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:19f9507bc3e037411f3a55355d954a8fc0a16f823dfb9566bd32f3aa0b934833", size = 892641, upload-time = "2026-07-17T18:59:27.083Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f2/1d7c1aaafded88178aa6ece604695ca886ce5df8d9f892beadd04c3b791c/rignore-0.8.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dedc11fe3ac6a4aa71f145b2c83ae8f4d07ba73dbb3fa56cd08aa3d770eec1e1", size = 962666, upload-time = "2026-07-17T18:59:29.17Z" }, + { url = "https://files.pythonhosted.org/packages/78/e4/0b9be868b4dae2d46d8aa5afdcce1a9884a7b60ece3117084674c5c4101d/rignore-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52e31ba2c4ae5a5efa73ab11e11b11739ee4fa3fe8abcbdd7f040f888ab76765", size = 1061632, upload-time = "2026-07-17T18:59:30.542Z" }, + { url = "https://files.pythonhosted.org/packages/92/63/ea45b9bc66295c1d18ba98184c6242f57dd1619a4ac78d4a08c679b2b49f/rignore-0.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1cd08c9715bd216640cd95ffb766861f33ab012a52dc772cc53b4deb76440219", size = 1132097, upload-time = "2026-07-17T18:59:32.234Z" }, + { url = "https://files.pythonhosted.org/packages/ce/1a/6f74fe3124f5901d01824ef172479bafc4a646137b1ba9e4d4c01cf99945/rignore-0.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad3ec7e37f7149b4eb9e1ab7a902fb505614ca14e12ba6f8e0308ef771e996b3", size = 1140242, upload-time = "2026-07-17T18:59:33.616Z" }, + { url = "https://files.pythonhosted.org/packages/f0/66/04951ceda27d83e473db84eadaccc1b308c8f1f0fb155ba0ce6b2ba61c3a/rignore-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:eb9ec9a4dd7085b0c0a4b6525e22c72444818c68a120f90f9ef20a5c993137b6", size = 1140353, upload-time = "2026-07-17T18:59:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/2c/56/3ad9ab039859cae2ab0ebf35c3eca679f1245c3568d5e8bc03ca74d6d3fb/rignore-0.8.0-cp314-cp314-win32.whl", hash = "sha256:3dc5c339a0ee2e351f0a4cec63f375739ba53897bacc76219678b0889650a506", size = 637993, upload-time = "2026-07-17T18:59:36.525Z" }, + { url = "https://files.pythonhosted.org/packages/f0/dc/866de21865bda01fea13638e706c1e5ebfa84e54619cff92cc2ea3b6b705/rignore-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:b11e160a6f6dac00270f732b65179c06414858f52ceef33e1d20348f2a7fd60b", size = 729317, upload-time = "2026-07-17T18:59:38.046Z" }, + { url = "https://files.pythonhosted.org/packages/af/03/7c4340f3675e0332030e2406ba4d5e2036db2158455eb4be1ee7a8754f90/rignore-0.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:a14d6b48a43cb802699ec91ff57211ce558c84ef64f8e40afe71370b37763804", size = 665377, upload-time = "2026-07-17T18:59:39.362Z" }, + { url = "https://files.pythonhosted.org/packages/a7/28/22d38ca061ce6e24f812ea2ab1992b300d3b2c66551ce065a960d9dfcabd/rignore-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e915da13ef7e1494c451cb363c24e9fba531cc7db93b4772d20e2f1902870d69", size = 845632, upload-time = "2026-07-17T18:59:40.704Z" }, + { url = "https://files.pythonhosted.org/packages/60/6e/aa828871acbd3e6dfe1448dfba5a13241684fd91e3d1d8823acafcf92ce5/rignore-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eb93f83feaf10f80a03ef0ea7e7a794fffbb756a8932190860735d134de31115", size = 815701, upload-time = "2026-07-17T18:59:41.983Z" }, + { url = "https://files.pythonhosted.org/packages/9b/7c/7e9ef7ff0ffb0316fb8a3d0f38cc16f72d1596593c3ff2a75800f20e495a/rignore-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f8d4d436f067a5f724fc8ac8ba779a8c408806b7d32e126e9539b002a9e06c2", size = 884554, upload-time = "2026-07-17T18:59:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/6b/56/bfdb913c57ae26a6f0804dd7be26615a8cd7e39844cc0b6da3c50524515a/rignore-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a7ebedf2065bd1136754ae3dd5b5893da5ec98d23d683b942c810fe93677909d", size = 856054, upload-time = "2026-07-17T18:59:44.946Z" }, + { url = "https://files.pythonhosted.org/packages/b7/cd/2312da57bdc365b7467d6857b0bcb74c08dfce59debec5dc8f243bbf0a99/rignore-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c92ca80e622102ec06d82606ed366c7c9e5e8d9d4f101ae3097a5e87e07f76e1", size = 1135507, upload-time = "2026-07-17T18:59:46.359Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/29cbde71521aa77540bd08523775219e48642876dfcfb9dd4f753933ede3/rignore-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c4eb2394fe45ef9abb64104e79c9b70a6a407708c965071072bcaac41735d11", size = 914693, upload-time = "2026-07-17T18:59:47.722Z" }, + { url = "https://files.pythonhosted.org/packages/5a/92/83182f17466ffb626060e5b5567cb163cfcef1f6264486b5270b55518472/rignore-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96cd84f82309b6737fcacbedfbc3611d58349a9e9c2a538a00262c2991478f9c", size = 927946, upload-time = "2026-07-17T18:59:49.369Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f0/f038a1b3d677716f63a39be9d56fd2d0d89c87eb5a3fe5a39c6e25799a85/rignore-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4d62068de382d18b2958fa2d3880baad97047c0cf18e8f46f5f49e3fd0d5d3a3", size = 891305, upload-time = "2026-07-17T18:59:50.992Z" }, + { url = "https://files.pythonhosted.org/packages/af/82/9e66abefcebfd5ea382d2c1f03b0e4900cea6a719f435ecc56339da03b05/rignore-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65bacba32c0c49f8c9499f919abc1c2e787dbd926cdbf69f49fdecb635e47673", size = 963896, upload-time = "2026-07-17T18:59:52.774Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f9/3a2697aba2e48e3e6875841c49222e7ba9a779f33f683ce9d7b16d2cd3c3/rignore-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1acd747ba926e8b35e3cda1dde17ea2b548fb58c6cf4b12cfc95559d064ae2c2", size = 1060829, upload-time = "2026-07-17T18:59:54.37Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9a/ba854d6937a0565ff669ce99aeef26a7250f12b6a3d92e2a6db12a9ebd8a/rignore-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2b25bbbd66c7dcd8198876a5dc3fb65efe075adce64cba62c120a5d6221ee7f", size = 1130894, upload-time = "2026-07-17T18:59:56.052Z" }, + { url = "https://files.pythonhosted.org/packages/51/ad/23824eca7320240b53ca1de83397a9fd92355f209b69902478a7d4f6dc1a/rignore-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a62125e3701f6731bf4fdbc32974783b5512937540b49559559d2ba0e086d36a", size = 1141322, upload-time = "2026-07-17T18:59:57.986Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5c/e06941a97cc49901ce93a9d045b5aef06a47288a5be283e092421d937754/rignore-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:69a07212f1b35a8f1cfce3df7f78a30ff8b81d2f4a223d40df5a78b770095fa7", size = 1138688, upload-time = "2026-07-17T18:59:59.613Z" }, + { url = "https://files.pythonhosted.org/packages/69/ae/00a872a463b38c3d693acd096133fa693f68597879e943f78b98274ba2d4/rignore-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:f48bd7d720d96078411a7fd905d0e20fc41bb15e24cfa9d5914c6fa5b25c48f0", size = 638522, upload-time = "2026-07-17T19:00:01.154Z" }, + { url = "https://files.pythonhosted.org/packages/ff/22/fd0e37ba32fc70573501a0d0196faf3a91ebc066514d133399cd85d9cd7c/rignore-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:738613a5d9451df01810884059fae605047188e7cdcea79eb4b0f03d866348bd", size = 729624, upload-time = "2026-07-17T19:00:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/3a/e5/b99c0384bc72d6bc37db31158cab7a1ef068c8c3fc9080d4ca0e1c949308/rignore-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caf1c51c60791cd9d6df46c2f82eed1634e1bf7859d116e36d56b9e69b1e9a71", size = 664583, upload-time = "2026-07-17T19:00:04.71Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.66.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/6f/d59cad0889d15fde85254cf58e701484de3f3f0406003b3197746910b19b/sentry_sdk-2.66.1.tar.gz", hash = "sha256:f882fb08710c5f8bfc603aafa3e901b384009a19cc3f76a572b863392ee81cdc", size = 940543, upload-time = "2026-07-22T12:26:54.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/d3/726bd88f0eece09ddf431bea4c9191c18e7a8d070b854eb0014d447712ee/sentry_sdk-2.66.1-py3-none-any.whl", hash = "sha256:86002793161d9a95ef04bdd8d442e9bfece5d989b755f05d6360215094a7aff6", size = 505555, upload-time = "2026-07-22T12:26:52.71Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +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/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]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typer" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "websockets" +version = "16.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, + { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, +]