From 17957a5f3c83b9ca27e6fac5b7d466739f071bf8 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Fri, 31 Jul 2026 11:14:41 +0200 Subject: [PATCH 01/50] chore: add `.python-version` and `uv.lock` for Python version management and dependency tracking, also add Makefile with lint command. --- .python-version | 1 + Makefile | 4 + pyproject.toml | 17 + uv.lock | 1203 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1225 insertions(+) create mode 100644 .python-version create mode 100644 Makefile create mode 100644 pyproject.toml create mode 100644 uv.lock 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..066787a --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: lint + +lint: + uv run ruff check . \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..519d96d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "testing-platform-backend" +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", + "pydantic[email]>=2.13.4", +] + +[dependency-groups] +dev = [ + "openapi-spec-validator>=0.9.0", + "pyright>=1.1.411", + "ruff>=0.16.0", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2fe2346 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1203 @@ +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 = "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 = "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 = "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 = "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 = "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 = "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 = "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 = "testing-platform-backend" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi", extra = ["standard"] }, + { name = "pydantic", extra = ["email"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "openapi-spec-validator" }, + { name = "pyright" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", extras = ["standard"], specifier = ">=0.139.2" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.13.4" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "openapi-spec-validator", specifier = ">=0.9.0" }, + { name = "pyright", specifier = ">=1.1.411" }, + { name = "ruff", specifier = ">=0.16.0" }, +] + +[[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" }, +] From 75ff3e02726342bd6c8d3dde9749918418d4a4e7 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Fri, 31 Jul 2026 11:15:34 +0200 Subject: [PATCH 02/50] chore: add OpenAPI export tool and configure `.gitattributes` for linguist-generated files --- .gitattributes | 2 ++ app/__init__.py | 0 app/tools/__init__.py | 0 app/tools/export_openapi.py | 27 +++++++++++++++++++++++++++ 4 files changed, 29 insertions(+) create mode 100644 .gitattributes create mode 100644 app/__init__.py create mode 100644 app/tools/__init__.py create mode 100644 app/tools/export_openapi.py 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/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tools/__init__.py b/app/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tools/export_openapi.py b/app/tools/export_openapi.py new file mode 100644 index 0000000..9434072 --- /dev/null +++ b/app/tools/export_openapi.py @@ -0,0 +1,27 @@ +""" +Export the generated OpenAPI document to `docs/openapi.json`. +Run: `uv run python -m app.tools.export_openapi` +""" + +import json +import sys +from pathlib import Path + +from app.main import app + +ROOT = Path(__file__).resolve().parents[2] +DEST = ROOT / "api" / "openapi.json" + + +def main() -> None: + spec = app.openapi() + DEST.write_text(json.dumps(spec, indent=2, ensure_ascii=False) + "\n") + print( + f"Generated {DEST.relative_to(ROOT)} " + f"(openapi {spec['openapi']}, {len(spec['paths'])} paths)", + file=sys.stderr, + ) + + +if __name__ == "__main__": + main() From 67dbe85cd55722ae92c31f9e632af8dc82a2de7c Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Fri, 31 Jul 2026 12:05:23 +0200 Subject: [PATCH 03/50] chore(makefile): add `export-openapi` target and update OpenAPI export instructions --- Makefile | 5 ++++- app/tools/export_openapi.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 066787a..d8db4d9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,7 @@ -.PHONY: lint +.PHONY: export-openapi lint + +export-openapi: + uv run python -m app.tools.export_openapi lint: uv run ruff check . \ No newline at end of file diff --git a/app/tools/export_openapi.py b/app/tools/export_openapi.py index 9434072..ce61c1c 100644 --- a/app/tools/export_openapi.py +++ b/app/tools/export_openapi.py @@ -1,6 +1,6 @@ """ -Export the generated OpenAPI document to `docs/openapi.json`. -Run: `uv run python -m app.tools.export_openapi` +Export the generated OpenAPI document to `api/openapi.json`. +Run: `uv run python -m app.tools.export_openapi` or `make export-openapi` """ import json From 87786ffd4e7b61ce4fab24fe38b94873cd1afc28 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Fri, 31 Jul 2026 15:43:04 +0200 Subject: [PATCH 04/50] chore: add `pytest` for testing, update `uv.lock` with new dependencies, adjust OpenAPI spec path, and set up CI workflow --- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++++++++++++++++++ README.md | 15 +++++++------- pyproject.toml | 5 +++++ uv.lock | 45 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f99563a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +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@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --frozen + + - name: Lint + run: uv run --frozen ruff check app + + - name: Type check + run: uv run --frozen pyright app + + - name: Validate OpenAPI spec + run: uv run --frozen openapi-spec-validator --schema 3.1 api/openapi.json + + - name: Check that the committed spec is up to date (drift guard) + run: | + uv run --frozen python -m app.tools.export_openapi + git diff --exit-code -- api/openapi.json \ + || { echo "::error::api/openapi.json is stale — run 'uv run python -m app.tools.export_openapi' and commit the result."; exit 1; } diff --git a/README.md b/README.md index 4482406..eb7e6d8 100644 --- a/README.md +++ b/README.md @@ -40,14 +40,14 @@ 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 (`app.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) +uv run python -m app.tools.export_openapi # write api/openapi.json +uv run openapi-spec-validator --schema 3.1 api/openapi.json # validate (exits 0 if valid) ``` -Regenerate and re-validate after any change to a router or Pydantic schema. `docs/openapi.json` is the contract the +Regenerate and re-validate after any change to a router or Pydantic schema. `api/openapi.json` is the contract the frontend interfaces with; commit it alongside the change that alters it. # Project structure @@ -68,8 +68,9 @@ app/ guest/ auth/ org/ assets/ scans/ schedules/ findings/ reports/ notifications/ health/ 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 +``` diff --git a/pyproject.toml b/pyproject.toml index 519d96d..ee18180 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,5 +13,10 @@ dependencies = [ dev = [ "openapi-spec-validator>=0.9.0", "pyright>=1.1.411", + "pytest>=9.1.1", "ruff>=0.16.0", ] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/uv.lock b/uv.lock index 2fe2346..9cf8040 100644 --- a/uv.lock +++ b/uv.lock @@ -306,6 +306,15 @@ 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" @@ -502,6 +511,15 @@ 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" @@ -511,6 +529,15 @@ 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" @@ -636,6 +663,22 @@ 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" @@ -960,6 +1003,7 @@ dependencies = [ dev = [ { name = "openapi-spec-validator" }, { name = "pyright" }, + { name = "pytest" }, { name = "ruff" }, ] @@ -973,6 +1017,7 @@ requires-dist = [ dev = [ { 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" }, ] From 93055aa27cbe99df4f92e9bd0ade01d5a69db227 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Mon, 3 Aug 2026 15:55:33 +0200 Subject: [PATCH 05/50] chore: refactor project structure to `src` layout, rename package to `nc3_testing_platform`, update references, and adjust tooling and CI configuration --- .github/workflows/ci.yml | 8 +- Makefile | 2 +- README.md | 10 +-- app/__init__.py | 0 app/tools/__init__.py | 0 pyproject.toml | 20 ++++- src/nc3_testing_platform/__init__.py | 1 + src/nc3_testing_platform/core/__init__.py | 1 + src/nc3_testing_platform/domains/__init__.py | 1 + src/nc3_testing_platform/main.py | 80 +++++++++++++++++++ src/nc3_testing_platform/tools/__init__.py | 1 + .../tools/export_openapi.py | 11 +-- uv.lock | 62 +++++++------- 13 files changed, 149 insertions(+), 48 deletions(-) delete mode 100644 app/__init__.py delete mode 100644 app/tools/__init__.py create mode 100644 src/nc3_testing_platform/__init__.py create mode 100644 src/nc3_testing_platform/core/__init__.py create mode 100644 src/nc3_testing_platform/domains/__init__.py create mode 100644 src/nc3_testing_platform/main.py create mode 100644 src/nc3_testing_platform/tools/__init__.py rename {app => src/nc3_testing_platform}/tools/export_openapi.py (60%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f99563a..eafdb50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,16 +30,16 @@ jobs: run: uv sync --frozen - name: Lint - run: uv run --frozen ruff check app + run: uv run --frozen ruff check src - name: Type check - run: uv run --frozen pyright app + run: uv run --frozen pyright src - name: Validate OpenAPI spec run: uv run --frozen openapi-spec-validator --schema 3.1 api/openapi.json - name: Check that the committed spec is up to date (drift guard) run: | - uv run --frozen python -m app.tools.export_openapi + uv run --frozen export-openapi git diff --exit-code -- api/openapi.json \ - || { echo "::error::api/openapi.json is stale — run 'uv run python -m app.tools.export_openapi' and commit the result."; exit 1; } + || { echo "::error::api/openapi.json is stale — run 'uv run export-openapi' and commit the result."; exit 1; } diff --git a/Makefile b/Makefile index d8db4d9..5a99a4d 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: export-openapi lint export-openapi: - uv run python -m app.tools.export_openapi + uv run export-openapi lint: uv run ruff check . \ No newline at end of file diff --git a/README.md b/README.md index eb7e6d8..a50e4dc 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ No environment variables or config are required yet. # Running the mock server ```bash -uv run fastapi dev app/main.py +uv run fastapi dev src/nc3_testing_platform/main.py ``` - API base: http://localhost:8000/api/v1 @@ -40,10 +40,10 @@ 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 `api/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 api/openapi.json +uv run export-openapi # write api/openapi.json uv run openapi-spec-validator --schema 3.1 api/openapi.json # validate (exits 0 if valid) ``` @@ -56,7 +56,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 @@ -72,5 +72,5 @@ app/ api/ openapi.json # generated API contract (see "Generating the OpenAPI contract") docs/ - reference/ # source design docs (data-model, ADRs) + reference/ # reference documentation ``` diff --git a/app/__init__.py b/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/tools/__init__.py b/app/tools/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/pyproject.toml b/pyproject.toml index ee18180..8dfd87b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "testing-platform-backend" +name = "nc3-testing-platform" version = "0.1.0" description = "v4.0 backend MVP for the NC3 Testing Platform." readme = "README.md" @@ -9,6 +9,9 @@ dependencies = [ "pydantic[email]>=2.13.4", ] +[project.scripts] +export-openapi = "nc3_testing_platform.tools.export_openapi:main" + [dependency-groups] dev = [ "openapi-spec-validator>=0.9.0", @@ -17,6 +20,19 @@ dev = [ "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"] -pythonpath = ["."] + +[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/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/main.py b/src/nc3_testing_platform/main.py new file mode 100644 index 0000000..27395d4 --- /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.4", + 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/app/tools/export_openapi.py b/src/nc3_testing_platform/tools/export_openapi.py similarity index 60% rename from app/tools/export_openapi.py rename to src/nc3_testing_platform/tools/export_openapi.py index ce61c1c..081b7cf 100644 --- a/app/tools/export_openapi.py +++ b/src/nc3_testing_platform/tools/export_openapi.py @@ -1,19 +1,20 @@ -""" -Export the generated OpenAPI document to `api/openapi.json`. -Run: `uv run python -m app.tools.export_openapi` or `make export-openapi` +"""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 app.main import app +from nc3_testing_platform.main import app -ROOT = Path(__file__).resolve().parents[2] +ROOT = Path(__file__).resolve().parents[3] DEST = ROOT / "api" / "openapi.json" def main() -> None: + """Entry point for the `export-openapi` project script.""" spec = app.openapi() DEST.write_text(json.dumps(spec, indent=2, ensure_ascii=False) + "\n") print( diff --git a/uv.lock b/uv.lock index 9cf8040..8f7410b 100644 --- a/uv.lock +++ b/uv.lock @@ -468,6 +468,37 @@ 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 = "pydantic", extra = ["email"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "openapi-spec-validator" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", extras = ["standard"], specifier = ">=0.139.2" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.13.4" }, +] + +[package.metadata.requires-dev] +dev = [ + { 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" @@ -990,37 +1021,6 @@ 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 = "testing-platform-backend" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "fastapi", extra = ["standard"] }, - { name = "pydantic", extra = ["email"] }, -] - -[package.dev-dependencies] -dev = [ - { name = "openapi-spec-validator" }, - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "fastapi", extras = ["standard"], specifier = ">=0.139.2" }, - { name = "pydantic", extras = ["email"], specifier = ">=2.13.4" }, -] - -[package.metadata.requires-dev] -dev = [ - { 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 = "typer" version = "0.27.0" From 2f9d9b02a95f257a522488647bd0c9f1999db371 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Mon, 3 Aug 2026 16:11:02 +0200 Subject: [PATCH 06/50] chore: add core enums, config constants, and OpenAPI schema registration logic for `nc3_testing_platform` --- src/nc3_testing_platform/core/config.py | 24 +++ src/nc3_testing_platform/core/enums.py | 208 +++++++++++++++++++++++ src/nc3_testing_platform/core/openapi.py | 38 +++++ 3 files changed, 270 insertions(+) create mode 100644 src/nc3_testing_platform/core/config.py create mode 100644 src/nc3_testing_platform/core/enums.py create mode 100644 src/nc3_testing_platform/core/openapi.py 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..910d194 --- /dev/null +++ b/src/nc3_testing_platform/core/enums.py @@ -0,0 +1,208 @@ +"""Closed enumerations of the v4 contract. + +Every enum mirrors a PostgreSQL enum type in the data model. + +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" + CANCELLED = "cancelled" + + +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" + CANCELLED = "cancelled" + + +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/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 From 9f859db99a209bae034be03c3d690027db573d0b Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Mon, 3 Aug 2026 16:12:24 +0200 Subject: [PATCH 07/50] chore: add domain-specific module placeholders --- README.md | 4 +--- src/nc3_testing_platform/domains/admin/__init__.py | 1 + src/nc3_testing_platform/domains/api_keys/__init__.py | 1 + src/nc3_testing_platform/domains/assets/__init__.py | 1 + src/nc3_testing_platform/domains/findings/__init__.py | 1 + src/nc3_testing_platform/domains/health/__init__.py | 1 + src/nc3_testing_platform/domains/notifications/__init__.py | 1 + src/nc3_testing_platform/domains/org/__init__.py | 1 + src/nc3_testing_platform/domains/reports/__init__.py | 1 + src/nc3_testing_platform/domains/scans/__init__.py | 1 + src/nc3_testing_platform/domains/schedules/__init__.py | 1 + src/nc3_testing_platform/domains/statements/__init__.py | 1 + 12 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 src/nc3_testing_platform/domains/admin/__init__.py create mode 100644 src/nc3_testing_platform/domains/api_keys/__init__.py create mode 100644 src/nc3_testing_platform/domains/assets/__init__.py create mode 100644 src/nc3_testing_platform/domains/findings/__init__.py create mode 100644 src/nc3_testing_platform/domains/health/__init__.py create mode 100644 src/nc3_testing_platform/domains/notifications/__init__.py create mode 100644 src/nc3_testing_platform/domains/org/__init__.py create mode 100644 src/nc3_testing_platform/domains/reports/__init__.py create mode 100644 src/nc3_testing_platform/domains/scans/__init__.py create mode 100644 src/nc3_testing_platform/domains/schedules/__init__.py create mode 100644 src/nc3_testing_platform/domains/statements/__init__.py diff --git a/README.md b/README.md index a50e4dc..333c2f2 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,7 @@ src/nc3_testing_platform/ 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 tools/ export_openapi.py # dumps app.openapi() -> api/openapi.json api/ 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/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/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/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/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/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/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/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/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/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/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.""" From 8dcea322a1696e6795beca6a2e746a7bd37aab94 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Mon, 3 Aug 2026 16:39:17 +0200 Subject: [PATCH 08/50] feat: add shared schemas module, and include API contract documentation --- api/README.md | 15 ++++++++++ src/nc3_testing_platform/core/schemas.py | 36 ++++++++++++++++++++++++ src/nc3_testing_platform/main.py | 2 +- 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 api/README.md create mode 100644 src/nc3_testing_platform/core/schemas.py 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/src/nc3_testing_platform/core/schemas.py b/src/nc3_testing_platform/core/schemas.py new file mode 100644 index 0000000..34fcf3f --- /dev/null +++ b/src/nc3_testing_platform/core/schemas.py @@ -0,0 +1,36 @@ +"""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 datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +# Wire values are UUIDv7, which is time-sortable. +ResourceId = UUID + +# Every timestamp in the contract is a UTC instant serialized as ISO-8601. +Timestamp = datetime + + +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.") \ No newline at end of file diff --git a/src/nc3_testing_platform/main.py b/src/nc3_testing_platform/main.py index 27395d4..84d0af4 100644 --- a/src/nc3_testing_platform/main.py +++ b/src/nc3_testing_platform/main.py @@ -38,7 +38,7 @@ app = FastAPI( title="NC3 Testing Platform API", - version="4.0.4", + version="4.0.1", summary="v4.0 backend MVP for the NC3 Testing Platform.", openapi_url="/api/v1/openapi.json", docs_url="/docs", From 211593068eed8bedc2c77489478af505fbb19de4 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Tue, 4 Aug 2026 12:17:17 +0200 Subject: [PATCH 09/50] feat: update shared schemas to use UUIDv7 for `ResourceId` and enforce UTC normalization for `Timestamp` --- src/nc3_testing_platform/core/enums.py | 4 ++-- src/nc3_testing_platform/core/schemas.py | 28 ++++++++++++++++++------ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/nc3_testing_platform/core/enums.py b/src/nc3_testing_platform/core/enums.py index 910d194..d9aaf9c 100644 --- a/src/nc3_testing_platform/core/enums.py +++ b/src/nc3_testing_platform/core/enums.py @@ -86,7 +86,7 @@ class ScanJobStatus(StrEnum): COMPLETED = "completed" PARTIAL = "partial" FAILED = "failed" - CANCELLED = "cancelled" + CANCELED = "canceled" class ScanModule(StrEnum): @@ -123,7 +123,7 @@ class ScanTaskStatus(StrEnum): FAILED = "failed" SKIPPED = "skipped" BLOCKED = "blocked" - CANCELLED = "cancelled" + CANCELED = "canceled" class ScanGrade(StrEnum): diff --git a/src/nc3_testing_platform/core/schemas.py b/src/nc3_testing_platform/core/schemas.py index 34fcf3f..344f40c 100644 --- a/src/nc3_testing_platform/core/schemas.py +++ b/src/nc3_testing_platform/core/schemas.py @@ -4,16 +4,30 @@ contract, and generated clients share one vocabulary. """ -from datetime import datetime -from uuid import UUID - -from pydantic import BaseModel, ConfigDict, Field +from datetime import UTC, datetime +from typing import Annotated + +from pydantic import ( + UUID7, + AfterValidator, + AwareDatetime, + BaseModel, + ConfigDict, + Field, +) # Wire values are UUIDv7, which is time-sortable. -ResourceId = UUID +ResourceId = UUID7 + # Every timestamp in the contract is a UTC instant serialized as ISO-8601. -Timestamp = datetime +def _normalize_to_utc(value: datetime) -> datetime: + return value.astimezone(UTC) + +Timestamp = Annotated[ + AwareDatetime, + AfterValidator(_normalize_to_utc), +] class BaseSchema(BaseModel): @@ -33,4 +47,4 @@ class SeverityCounts(BaseModel): 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.") \ No newline at end of file + info: int = Field(default=0, ge=0, description="Findings in the informational band.") From 062ad88f7a4898460cedffdb8a6ad2d763e4f5e8 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Tue, 4 Aug 2026 13:04:10 +0200 Subject: [PATCH 10/50] chore: add .editorconfig for consistent code style settings --- .editorconfig | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .editorconfig 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 From f70225f662037175ac591b9df5156b6db84e099f Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Tue, 4 Aug 2026 16:13:30 +0200 Subject: [PATCH 11/50] chore: expand README with layout details for domain-specific modules --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 333c2f2..1771a8f 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,12 @@ src/nc3_testing_platform/ pagination.py # cursor pagination security.py # OpenAPI security schemes + rate-limit contract 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() -> api/openapi.json api/ From 39cc34e64ad576b4ccb4d2238437a45dc6f7cfa3 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Tue, 4 Aug 2026 16:46:42 +0200 Subject: [PATCH 12/50] docs: add detailed API contract and data model documentation for `v4.0.1` --- docs/reference/api-design-v4_0_1.md | 367 ++++++++++++++ docs/reference/data-model-v4_0_1.md | 759 ++++++++++++++++++++++++++++ 2 files changed, 1126 insertions(+) create mode 100644 docs/reference/api-design-v4_0_1.md create mode 100644 docs/reference/data-model-v4_0_1.md 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..715a26b --- /dev/null +++ b/docs/reference/api-design-v4_0_1.md @@ -0,0 +1,367 @@ +# 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. +- Collection endpoints use cursor pagination and stable ordering. +- 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 canonical domain as free text — the only place in the API where free target text exists. 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 no authentication: + +```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. + +### 5.1 Verification + +Verification is a separate resource nested under the Asset, with zero or one current state. + +- `POST .../verification` creates a challenge with the requested `exact` or `zone` scope. +- `POST .../checks` triggers a DNS check. +- `POST .../token` replaces the challenge token. + +The response 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 without a successful check. `verified` is terminal and holds past that deadline. +- `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 .../checks` sets `last_recheck_at` whether or not the record was found. A not-found result answers `200` with the status still `pending` and a `failure_code`. +- Verification statuses are `pending`, `verified`, and `expired`. +- Current MFA assurance is read from the identity provider's session or token. +- 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: `scan_job_id` or `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. +- Every key-management operation consumes current MFA assurance. +- Revocation also happens outside this resource: a password change revokes that user's keys, and account erasure revokes and deletes them. +- `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..df14c3a --- /dev/null +++ b/docs/reference/data-model-v4_0_1.md @@ -0,0 +1,759 @@ +# 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 UUIDs. +- 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` | `verification_status` | `pending`, `verified` | +| `domain_verification` | `verification_scope` | `exact`, `zone` | +| `domain_verification` | `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. + +### 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; canonical IDNA domain without a trailing dot | +| `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. + +### 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` | +| `status` | `verification_status` | Not null | +| `requested_scope` | `verification_scope` | Not null | +| `verified_scope` | `verification_scope` | Nullable | +| `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 | +| `verified_at` | timestamptz | Nullable | +| `last_recheck_at` | timestamptz | Nullable | +| `failure_code` | text | Nullable | +| `updated_at` | timestamptz | Not null | + +Constraints: + +- The referenced Asset must have `asset_type = domain`. +- `verified_scope` is non-null only when `status = verified`. +- Changing `requested_scope` does not change `verified_scope` until verification succeeds. +- Zone coverage is evaluated by DNS-label ancestry, not by string suffix matching. +- `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 verification in `pending` whose `token_expires_at` has passed is expired. Expiry is derived from that timestamp, so `verification_status` carries no `expired` value. The API exposes `expired` as a computed status. +- `verified` is terminal. Reaching `token_expires_at` does not change a verification that already succeeded. +- Replacing the token while `status = verified` is rejected. Re-proving ownership or widening scope starts a new challenge, and `verified_scope` 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. +- This table stores the current state only. Verification attempts and status changes are recorded in `audit_event`. + +```mermaid +erDiagram + ORGANIZATION ||--o{ ASSET: owns + ASSET o|--o{ ASSET: discovers + ASSET ||--o| DOMAIN_VERIFICATION: has + APP_USER o|--o{ ASSET: creates + APP_USER o|--o{ DOMAIN_VERIFICATION: 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. +- 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; canonical 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. +- When `claimed_by_user_id` is set: `organization_id` is the claimed user's organization, and `claimed_at` is not null. 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. +- Password changes revoke user-owned keys. +- 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 its status is `verified` 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` 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. + From 0447ab56ca58d91c6bf2119b40e7947d16a917bc Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Tue, 4 Aug 2026 16:48:40 +0200 Subject: [PATCH 13/50] feat: add OpenAPI security schemes and rate-limit response contract for API authentication --- src/nc3_testing_platform/core/security.py | 110 ++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/nc3_testing_platform/core/security.py diff --git a/src/nc3_testing_platform/core/security.py b/src/nc3_testing_platform/core/security.py new file mode 100644 index 0000000..8b51593 --- /dev/null +++ b/src/nc3_testing_platform/core/security.py @@ -0,0 +1,110 @@ +"""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`. Key management consumes current MFA assurance." + ), +) + +OidcAuth = Annotated[str | None, Depends(oidc)] +ApiKeyAuth = Annotated[str | None, Depends(api_key)] + + +def require_authentication(oidc: 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 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) + +# 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, + } + } From b049c56e935dcf19f22b8b75384653d458d8843a Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Tue, 4 Aug 2026 16:48:55 +0200 Subject: [PATCH 14/50] feat: implement RFC 9457-compliant `application/problem+json` error handling and integrate with FastAPI --- src/nc3_testing_platform/core/errors.py | 194 ++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 src/nc3_testing_platform/core/errors.py diff --git a/src/nc3_testing_platform/core/errors.py b/src/nc3_testing_platform/core/errors.py new file mode 100644 index 0000000..d8cd3fa --- /dev/null +++ b/src/nc3_testing_platform/core/errors.py @@ -0,0 +1,194 @@ +"""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), + ) + + +def register_exception_handlers(app: FastAPI) -> None: + """Route HTTP + validation 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] + + +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 From 1622775f418406ffad33e316c44ee7f8dc7259f8 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Tue, 4 Aug 2026 16:49:05 +0200 Subject: [PATCH 15/50] feat: add cursor-based pagination dependency and schema for paginated API responses --- src/nc3_testing_platform/core/pagination.py | 52 +++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/nc3_testing_platform/core/pagination.py 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.", + ) From 908e95851d020e8fff3e356c33d8c2a787946cb2 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Tue, 4 Aug 2026 16:49:22 +0200 Subject: [PATCH 16/50] feat: implement initial routers, schemas, and domain-specific modules for API v4.0 --- .../domains/admin/router.py | 77 +++ .../domains/admin/schemas.py | 87 ++++ .../domains/api_keys/router.py | 97 ++++ .../domains/api_keys/schemas.py | 81 +++ .../domains/assets/examples.py | 117 +++++ .../domains/assets/router.py | 254 +++++++++ .../domains/assets/schemas.py | 183 +++++++ .../domains/findings/router.py | 65 +++ .../domains/health/router.py | 29 ++ .../domains/notifications/router.py | 181 +++++++ .../domains/notifications/schemas.py | 102 ++++ .../domains/org/router.py | 190 +++++++ .../domains/org/schemas.py | 85 ++++ .../domains/reports/router.py | 118 +++++ .../domains/reports/schemas.py | 67 +++ .../domains/scans/dependencies.py | 155 ++++++ .../domains/scans/examples.py | 329 ++++++++++++ .../domains/scans/models.py | 15 + .../domains/scans/repository.py | 18 + .../domains/scans/router.py | 316 ++++++++++++ .../domains/scans/schemas.py | 481 ++++++++++++++++++ .../domains/scans/service.py | 23 + .../domains/schedules/router.py | 101 ++++ .../domains/schedules/schemas.py | 80 +++ .../domains/statements/router.py | 78 +++ .../domains/statements/schemas.py | 83 +++ 26 files changed, 3412 insertions(+) create mode 100644 src/nc3_testing_platform/domains/admin/router.py create mode 100644 src/nc3_testing_platform/domains/admin/schemas.py create mode 100644 src/nc3_testing_platform/domains/api_keys/router.py create mode 100644 src/nc3_testing_platform/domains/api_keys/schemas.py create mode 100644 src/nc3_testing_platform/domains/assets/examples.py create mode 100644 src/nc3_testing_platform/domains/assets/router.py create mode 100644 src/nc3_testing_platform/domains/assets/schemas.py create mode 100644 src/nc3_testing_platform/domains/findings/router.py create mode 100644 src/nc3_testing_platform/domains/health/router.py create mode 100644 src/nc3_testing_platform/domains/notifications/router.py create mode 100644 src/nc3_testing_platform/domains/notifications/schemas.py create mode 100644 src/nc3_testing_platform/domains/org/router.py create mode 100644 src/nc3_testing_platform/domains/org/schemas.py create mode 100644 src/nc3_testing_platform/domains/reports/router.py create mode 100644 src/nc3_testing_platform/domains/reports/schemas.py create mode 100644 src/nc3_testing_platform/domains/scans/dependencies.py create mode 100644 src/nc3_testing_platform/domains/scans/examples.py create mode 100644 src/nc3_testing_platform/domains/scans/models.py create mode 100644 src/nc3_testing_platform/domains/scans/repository.py create mode 100644 src/nc3_testing_platform/domains/scans/router.py create mode 100644 src/nc3_testing_platform/domains/scans/schemas.py create mode 100644 src/nc3_testing_platform/domains/scans/service.py create mode 100644 src/nc3_testing_platform/domains/schedules/router.py create mode 100644 src/nc3_testing_platform/domains/schedules/schemas.py create mode 100644 src/nc3_testing_platform/domains/statements/router.py create mode 100644 src/nc3_testing_platform/domains/statements/schemas.py 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/router.py b/src/nc3_testing_platform/domains/api_keys/router.py new file mode 100644 index 0000000..d230cd4 --- /dev/null +++ b/src/nc3_testing_platform/domains/api_keys/router.py @@ -0,0 +1,97 @@ +"""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 +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=[Authenticated], +) +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=[Authenticated], +) +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. Revocation also happens outside this endpoint: changing a password + revokes that user's keys, and erasing an account revokes and deletes them. + """ + 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/examples.py b/src/nc3_testing_platform/domains/assets/examples.py new file mode 100644 index 0000000..58008c1 --- /dev/null +++ b/src/nc3_testing_platform/domains/assets/examples.py @@ -0,0 +1,117 @@ +"""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, +) +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") +_VERIFICATION_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_verification( + status: VerificationStatus = VerificationStatus.VERIFIED, + checked: bool = False, +) -> DomainVerification: + """A zone-scoped verification in the given state. + + `checked` marks a state that a lookup has just produced, which is what sets + `last_recheck_at`. + """ + verified = status == VerificationStatus.VERIFIED + return DomainVerification( + id=_VERIFICATION_ID, + asset_id=ASSET_ID, + status=status, + requested_scope=VerificationScope.ZONE, + verified_scope=VerificationScope.ZONE if verified else None, + verification_token="verify-4f7a2c9e1b8d3056", + record_name=verification_record_name("example.lu"), + token_expires_at=_T0 + VERIFICATION_TOKEN_TTL, + requested_by_user_id=USER_ID, + requested_at=_T0, + verified_at=_T0 if verified else None, + last_recheck_at=_T1 if checked else None, + failure_code=None if verified else "dns.txt_record_not_found", + updated_at=_T1, + ) + + +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..7e0bad1 --- /dev/null +++ b/src/nc3_testing_platform/domains/assets/router.py @@ -0,0 +1,254 @@ +"""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 +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.""" + 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=[Authenticated], +) +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. + """ + return examples.sample_verification(status=VerificationStatus.PENDING) + + +@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..6881022 --- /dev/null +++ b/src/nc3_testing_platform/domains/assets/schemas.py @@ -0,0 +1,183 @@ +"""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 with zero or one current state, 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, ResourceId, Timestamp + + +class Asset(BaseSchema): + """An organization-owned monitored domain.""" + + id: ResourceId + organization_id: ResourceId + asset_type: AssetType + value: str = Field( + description="Canonical IDNA domain 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: str = Field( + description="Canonical IDNA domain 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 DomainVerification(BaseSchema): + """Current state of one domain-ownership challenge. + + Only the current state lives here. Attempts and transitions are recorded in + the audit log. + """ + + id: ResourceId + asset_id: ResourceId + status: VerificationStatus + requested_scope: VerificationScope = Field( + description="Coverage the user asked for." + ) + verified_scope: VerificationScope | None = Field( + default=None, + description=( + "Coverage actually proven. Non-null only when verified. Re-requesting a " + "wider scope does not widen this until a check succeeds, so an existing " + "proof is never silently upgraded." + ), + ) + 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 moves an unanswered challenge to `expired`; a " + "verification that already succeeded is unaffected." + ) + ) + requested_by_user_id: ResourceId | None = None + requested_at: Timestamp + verified_at: Timestamp | None = None + 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.", + ) + updated_at: Timestamp + + +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/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/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/router.py b/src/nc3_testing_platform/domains/notifications/router.py new file mode 100644 index 0000000..092f039 --- /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.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) + + +@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, + ) + + +@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/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/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..f64762c --- /dev/null +++ b/src/nc3_testing_platform/domains/reports/schemas.py @@ -0,0 +1,67 @@ +"""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 + + +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/dependencies.py b/src/nc3_testing_platform/domains/scans/dependencies.py new file mode 100644 index 0000000..975f577 --- /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() + 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..86d582c --- /dev/null +++ b/src/nc3_testing_platform/domains/scans/repository.py @@ -0,0 +1,18 @@ +"""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, per +# docs/architecture/scan-launch-and-upload-handling.md §1. +# 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..f8a2f8e --- /dev/null +++ b/src/nc3_testing_platform/domains/scans/router.py @@ -0,0 +1,316 @@ +"""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, + 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(401, 404), +) +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(401, 404), +) +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(401, 404)}, +) +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..3ed4b8a --- /dev/null +++ b/src/nc3_testing_platform/domains/scans/schemas.py @@ -0,0 +1,481 @@ +"""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, + 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: str | 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: str | 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: str = Field( + description="Canonical IDNA domain 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/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/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.", + ) From c7eb7a55e90218948caed297d366d41530db9410 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Tue, 4 Aug 2026 16:50:00 +0200 Subject: [PATCH 17/50] feat(api/openapi.json): add OpenAPI contract for NC3 Testing Platform API v4.0.1 --- api/openapi.json | 8343 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 8343 insertions(+) create mode 100644 api/openapi.json diff --git a/api/openapi.json b/api/openapi.json new file mode 100644 index 0000000..e5982aa --- /dev/null +++ b/api/openapi.json @@ -0,0 +1,8343 @@ +{ + "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", + "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" + } + } + } + }, + "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": [ + "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", + "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" + } + } + } + }, + "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}/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", + "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" + } + ] + } + } + } + }, + "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}/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.", + "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.", + "operationId": "create_verification_api_v1_assets__asset_id__verification_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/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/{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/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/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": [] + }, + { + "ApiKey": [] + } + ], + "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. Revocation also happens outside this endpoint: changing a password\nrevokes that user's keys, and erasing an account revokes and deletes them.", + "operationId": "revoke_api_key_api_v1_api_keys__key_id__revoke_post", + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ], + "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 IDNA domain 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": "Canonical IDNA domain 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": { + "id": { + "type": "string", + "format": "uuid7", + "title": "Id" + }, + "asset_id": { + "type": "string", + "format": "uuid7", + "title": "Asset Id" + }, + "status": { + "$ref": "#/components/schemas/VerificationStatus" + }, + "requested_scope": { + "$ref": "#/components/schemas/VerificationScope", + "description": "Coverage the user asked for." + }, + "verified_scope": { + "anyOf": [ + { + "$ref": "#/components/schemas/VerificationScope" + }, + { + "type": "null" + } + ], + "description": "Coverage actually proven. Non-null only when verified. Re-requesting a wider scope does not widen this until a check succeeds, so an existing proof is never silently upgraded." + }, + "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 moves an unanswered challenge to `expired`; a verification that already succeeded 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" + }, + "verified_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Verified 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." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "asset_id", + "status", + "requested_scope", + "record_name", + "verification_token", + "token_expires_at", + "requested_at", + "updated_at" + ], + "title": "DomainVerification", + "description": "Current state of one domain-ownership challenge.\n\nOnly the current state lives here. Attempts and transitions are recorded in\nthe 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." + }, + "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": "Canonical IDNA domain 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`. Key management consumes current MFA assurance.", + "in": "header", + "name": "Authorization" + } + } + } +} From e021aa786b81f1e7b74d1c895f31fcf2e72e7d28 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Tue, 4 Aug 2026 17:02:01 +0200 Subject: [PATCH 18/50] chore: update README and Makefile to streamline local development with `make dev` --- Makefile | 7 +++++-- README.md | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 5a99a4d..923fdba 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,10 @@ -.PHONY: export-openapi lint +.PHONY: dev export-openapi lint + +dev: + uv run fastapi dev src/nc3_testing_platform/main.py export-openapi: uv run export-openapi lint: - uv run ruff check . \ No newline at end of file + uv run ruff check . diff --git a/README.md b/README.md index 1771a8f..2e7f4a9 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ No environment variables or config are required yet. # Running the mock server ```bash -uv run fastapi dev src/nc3_testing_platform/main.py +make dev ``` - API base: http://localhost:8000/api/v1 From 7b0cbc75154ff32db4a1ef2081030d7b4e89d689 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Tue, 4 Aug 2026 17:49:54 +0200 Subject: [PATCH 19/50] chore(ci): update checkout action to v7 in GitHub workflow --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eafdb50..e60f8e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up uv uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 From 3da0d56fdad3f51ff40a9c903123a6920e1c5c80 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Tue, 4 Aug 2026 19:09:25 +0200 Subject: [PATCH 20/50] test: add contract tests for OpenAPI validation and consistency, update README and Makefile for test integration --- Makefile | 5 +- README.md | 11 +- .../tools/export_openapi.py | 8 +- tests/test_openapi_export.py | 104 ++++++++++++++++++ 4 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 tests/test_openapi_export.py diff --git a/Makefile b/Makefile index 923fdba..531df37 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev export-openapi lint +.PHONY: dev export-openapi lint test dev: uv run fastapi dev src/nc3_testing_platform/main.py @@ -8,3 +8,6 @@ export-openapi: lint: uv run ruff check . + +test: + uv run pytest diff --git a/README.md b/README.md index 2e7f4a9..146af81 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: @@ -43,12 +43,13 @@ Handlers return static stub data, so the running server doubles as a mock the fr 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 export-openapi # write api/openapi.json -uv run openapi-spec-validator --schema 3.1 api/openapi.json # validate (exits 0 if valid) +make export-openapi # write api/openapi.json +make test # validate it, and check the committed file is current ``` -Regenerate and re-validate after any change to a router or Pydantic schema. `api/openapi.json` is the contract the -frontend interfaces with; commit it alongside the change that alters it. +Regenerate after any change to a router or Pydantic schema. `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 diff --git a/src/nc3_testing_platform/tools/export_openapi.py b/src/nc3_testing_platform/tools/export_openapi.py index 081b7cf..f9d54e3 100644 --- a/src/nc3_testing_platform/tools/export_openapi.py +++ b/src/nc3_testing_platform/tools/export_openapi.py @@ -6,6 +6,7 @@ import json import sys from pathlib import Path +from typing import Any from nc3_testing_platform.main import app @@ -13,10 +14,15 @@ 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(json.dumps(spec, indent=2, ensure_ascii=False) + "\n") + DEST.write_text(render(spec)) print( f"Generated {DEST.relative_to(ROOT)} " f"(openapi {spec['openapi']}, {len(spec['paths'])} paths)", diff --git a/tests/test_openapi_export.py b/tests/test_openapi_export.py new file mode 100644 index 0000000..541dd09 --- /dev/null +++ b/tests/test_openapi_export.py @@ -0,0 +1,104 @@ +"""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() == 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_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") + if not content: + continue + assert "application/problem+json" in content, ( + f"{method.upper()} {path} {status} is not problem+json" + ) From 6a05396afe9a3be32f29814812f2d49f3895e4f4 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Tue, 4 Aug 2026 19:10:26 +0200 Subject: [PATCH 21/50] chore(ci): replace checkout action version with hash expression and replace OpenAPI spec validation with pytest --- .github/workflows/ci.yml | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e60f8e7..682a942 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up uv uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 @@ -35,11 +35,5 @@ jobs: - name: Type check run: uv run --frozen pyright src - - name: Validate OpenAPI spec - run: uv run --frozen openapi-spec-validator --schema 3.1 api/openapi.json - - - name: Check that the committed spec is up to date (drift guard) - run: | - uv run --frozen export-openapi - git diff --exit-code -- api/openapi.json \ - || { echo "::error::api/openapi.json is stale — run 'uv run export-openapi' and commit the result."; exit 1; } + - name: Test + run: uv run --frozen pytest From a3d27248b196b5b156be50d8a3bdda6488060f9b Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Tue, 4 Aug 2026 19:15:58 +0200 Subject: [PATCH 22/50] fix(notifications): move catch-all `dismiss_notification` endpoint in router to after the unreachable delete endpoint --- api/openapi.json | 130 +++++++++--------- .../domains/notifications/router.py | 32 ++--- 2 files changed, 81 insertions(+), 81 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index e5982aa..68aa80c 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -2917,71 +2917,6 @@ } } }, - "/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/notifications/webhook": { "get": { "tags": [ @@ -3161,6 +3096,71 @@ ] } }, + "/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": [ diff --git a/src/nc3_testing_platform/domains/notifications/router.py b/src/nc3_testing_platform/domains/notifications/router.py index 092f039..58dfe71 100644 --- a/src/nc3_testing_platform/domains/notifications/router.py +++ b/src/nc3_testing_platform/domains/notifications/router.py @@ -81,22 +81,6 @@ async def mark_read(notification_id: ResourceId) -> Notification: return notification -@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) - - @router.get( "/webhook", summary="Get the organization webhook", @@ -146,6 +130,22 @@ def _sample_webhook() -> OrganizationWebhook: ) +@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", From 2924d97ec4a7ff948f27357889fd747f522a9a4a Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Tue, 4 Aug 2026 19:19:30 +0200 Subject: [PATCH 23/50] refactor(api): remove 401 Unauthorized response from endpoints that also allow guest operations, and keep failures as 404. --- api/openapi.json | 30 ------------------- .../domains/scans/router.py | 6 ++-- 2 files changed, 3 insertions(+), 33 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 68aa80c..120d9b7 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -251,16 +251,6 @@ } } }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetail" - } - } - } - }, "404": { "description": "Not Found", "content": { @@ -410,16 +400,6 @@ } } }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetail" - } - } - } - }, "404": { "description": "Not Found", "content": { @@ -505,16 +485,6 @@ } } }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetail" - } - } - } - }, "404": { "description": "Not Found", "content": { diff --git a/src/nc3_testing_platform/domains/scans/router.py b/src/nc3_testing_platform/domains/scans/router.py index f8a2f8e..074cb05 100644 --- a/src/nc3_testing_platform/domains/scans/router.py +++ b/src/nc3_testing_platform/domains/scans/router.py @@ -170,7 +170,7 @@ async def list_scans(page: CursorPage) -> Page[ScanJob]: @router.get( "/{scan_id}", summary="Get a scan with its task snapshot", - responses=problem_responses(401, 404), + responses=problem_responses(404), ) async def get_scan( scan_id: ResourceId, claim_token: ScanAccessToken = None @@ -188,7 +188,7 @@ async def get_scan( @router.get( "/{scan_id}/results", summary="Get scan results", - responses=problem_responses(401, 404), + responses=problem_responses(404), ) async def get_scan_results( scan_id: ResourceId, claim_token: ScanAccessToken = None @@ -205,7 +205,7 @@ async def get_scan_results( "/{scan_id}/events", summary="Stream live scan progress", response_class=StreamingResponse, - responses={**_EVENT_STREAM_RESPONSE, **problem_responses(401, 404)}, + responses={**_EVENT_STREAM_RESPONSE, **problem_responses(404)}, ) async def stream_scan_events( scan_id: ResourceId, claim_token: ScanAccessToken = None From 844aa4973337241c3da828946884d15542b031aa Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Tue, 4 Aug 2026 19:25:04 +0200 Subject: [PATCH 24/50] chore(ci): replace `--frozen` with `--locked` in workflow commands for dependency resolution consistency --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 682a942..eb97d29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,13 +27,13 @@ jobs: enable-cache: true - name: Install dependencies - run: uv sync --frozen + run: uv sync --locked - name: Lint - run: uv run --frozen ruff check src + run: uv run --locked ruff check . - name: Type check - run: uv run --frozen pyright src + run: uv run --locked pyright - name: Test - run: uv run --frozen pytest + run: uv run --locked pytest From 5cce02d8aa77aa3d7623e268f2143620f424f716 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Tue, 4 Aug 2026 19:27:57 +0200 Subject: [PATCH 25/50] fix(export-openapi): specify UTF-8 encoding for reading and writing OpenAPI spec --- src/nc3_testing_platform/tools/export_openapi.py | 2 +- tests/test_openapi_export.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/nc3_testing_platform/tools/export_openapi.py b/src/nc3_testing_platform/tools/export_openapi.py index f9d54e3..b5fb27c 100644 --- a/src/nc3_testing_platform/tools/export_openapi.py +++ b/src/nc3_testing_platform/tools/export_openapi.py @@ -22,7 +22,7 @@ def render(spec: dict[str, Any]) -> str: def main() -> None: """Entry point for the `export-openapi` project script.""" spec = app.openapi() - DEST.write_text(render(spec)) + DEST.write_text(render(spec), encoding="utf-8") print( f"Generated {DEST.relative_to(ROOT)} " f"(openapi {spec['openapi']}, {len(spec['paths'])} paths)", diff --git a/tests/test_openapi_export.py b/tests/test_openapi_export.py index 541dd09..188b767 100644 --- a/tests/test_openapi_export.py +++ b/tests/test_openapi_export.py @@ -50,7 +50,7 @@ def test_spec_declares_openapi_3_1(spec: dict[str, Any]) -> None: 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() == render(spec), ( + assert Path(DEST).read_text(encoding="utf-8") == render(spec), ( "api/openapi.json is stale — run 'make export-openapi'" ) @@ -97,8 +97,7 @@ def test_error_responses_use_problem_details(spec: dict[str, Any]) -> None: if not status.startswith(("4", "5")): continue content = response.get("content") - if not content: - continue + 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" ) From 71791c46b020a00f0430084c052401a15ac71c2b Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Wed, 5 Aug 2026 07:07:57 +0200 Subject: [PATCH 26/50] refactor(api): canonicalize domain names to lowercase IDNA A-label form across API and reflect it in docs --- api/openapi.json | 6 +-- docs/reference/api-design-v4_0_1.md | 2 +- docs/reference/data-model-v4_0_1.md | 50 ++++++++++++++++++- src/nc3_testing_platform/core/schemas.py | 25 ++++++++++ .../domains/assets/schemas.py | 18 +++++-- .../domains/scans/schemas.py | 12 +++-- 6 files changed, 98 insertions(+), 15 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 120d9b7..35190a1 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -5027,7 +5027,7 @@ "value": { "type": "string", "title": "Value", - "description": "Canonical IDNA domain without a trailing dot.", + "description": "Canonical domain: lowercase IDNA (A-label) form without a trailing dot.", "examples": [ "example.lu" ] @@ -5097,7 +5097,7 @@ "value": { "type": "string", "title": "Value", - "description": "Canonical IDNA domain without a trailing dot.", + "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" ] @@ -8177,7 +8177,7 @@ "type": "array" }, "target": { - "description": "Canonical IDNA domain without a trailing dot.", + "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" ], diff --git a/docs/reference/api-design-v4_0_1.md b/docs/reference/api-design-v4_0_1.md index 715a26b..031ba7a 100644 --- a/docs/reference/api-design-v4_0_1.md +++ b/docs/reference/api-design-v4_0_1.md @@ -33,7 +33,7 @@ POST /api/v1/scans/{scan_id}/claim `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 canonical domain as free text — the only place in the API where free target text exists. 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). +- **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. diff --git a/docs/reference/data-model-v4_0_1.md b/docs/reference/data-model-v4_0_1.md index df14c3a..19d754f 100644 --- a/docs/reference/data-model-v4_0_1.md +++ b/docs/reference/data-model-v4_0_1.md @@ -208,7 +208,7 @@ erDiagram | `id` | UUID | Primary key | | `organization_id` | UUID | Not null; foreign key to `organization.id` | | `asset_type` | `asset_type` | Not null | -| `value` | text | Not null; canonical IDNA domain without a trailing dot | +| `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` | @@ -380,7 +380,7 @@ erDiagram | `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; canonical domain not stored as an Asset | +| `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 `{}` | @@ -757,3 +757,49 @@ erDiagram 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". Cross-row and cross-table rules stay in §13. + +| Table | Constraint | Enforces | +|---|---|---| +| `organization_invitation` | `(accepted_at IS NULL) = (accepted_by_user_id IS NULL)` | acceptance records the actor and the time together | +| `asset` | `parent_asset_id IS NULL OR origin = 'discovered'` | only discovery produces child assets | +| `domain_verification` | `(status = 'verified') = (verified_scope IS NOT NULL)` | proven coverage exists exactly on the verified state | +| `domain_verification` | `(status = 'verified') = (verified_at IS NOT NULL)` | the proof timestamp exists exactly on the verified state | +| `domain_verification` | `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_at IS NULL) = (claimed_by_user_id IS NULL)` | claiming records the actor and the time together | +| `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 NULL OR module = 'file'` | only File tasks reference an upload | +| `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/src/nc3_testing_platform/core/schemas.py b/src/nc3_testing_platform/core/schemas.py index 344f40c..ec6fe09 100644 --- a/src/nc3_testing_platform/core/schemas.py +++ b/src/nc3_testing_platform/core/schemas.py @@ -7,6 +7,7 @@ from datetime import UTC, datetime from typing import Annotated +import idna from pydantic import ( UUID7, AfterValidator, @@ -30,6 +31,30 @@ def _normalize_to_utc(value: datetime) -> datetime: ] +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. + """ + candidate = value[:-1] if value.endswith(".") else value + try: + canonical = idna.encode(candidate, uts46=True).decode("ascii") + 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.""" diff --git a/src/nc3_testing_platform/domains/assets/schemas.py b/src/nc3_testing_platform/domains/assets/schemas.py index 6881022..fd5e5d1 100644 --- a/src/nc3_testing_platform/domains/assets/schemas.py +++ b/src/nc3_testing_platform/domains/assets/schemas.py @@ -18,7 +18,12 @@ VerificationScope, VerificationStatus, ) -from nc3_testing_platform.core.schemas import BaseSchema, ResourceId, Timestamp +from nc3_testing_platform.core.schemas import ( + BaseSchema, + DomainName, + ResourceId, + Timestamp, +) class Asset(BaseSchema): @@ -27,8 +32,8 @@ class Asset(BaseSchema): id: ResourceId organization_id: ResourceId asset_type: AssetType - value: str = Field( - description="Canonical IDNA domain without a trailing dot.", + value: DomainName = Field( + description="Canonical domain: lowercase IDNA (A-label) form without a trailing dot.", examples=["example.lu"], ) origin: AssetOrigin = Field( @@ -53,8 +58,11 @@ class Asset(BaseSchema): class AssetCreate(BaseModel): """Register a domain to monitor.""" - value: str = Field( - description="Canonical IDNA domain without a trailing dot.", + 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 diff --git a/src/nc3_testing_platform/domains/scans/schemas.py b/src/nc3_testing_platform/domains/scans/schemas.py index 3ed4b8a..8ffbc47 100644 --- a/src/nc3_testing_platform/domains/scans/schemas.py +++ b/src/nc3_testing_platform/domains/scans/schemas.py @@ -32,6 +32,7 @@ ) from nc3_testing_platform.core.schemas import ( BaseSchema, + DomainName, ResourceId, SeverityCounts, Timestamp, @@ -198,7 +199,7 @@ class ScanTask(BaseSchema): ) classification: ScanClassification target_asset_id: ResourceId | None = None - target_domain: str | None = Field( + target_domain: DomainName | None = Field( default=None, description="Set for a guest target or a discovered subdomain with no Asset row.", ) @@ -267,7 +268,7 @@ class ScanJob(BaseSchema): description="Attribution only. Becomes null if the user is erased.", ) asset_id: ResourceId | None = None - target_domain: str | None = Field( + target_domain: DomainName | None = Field( default=None, description=( "Canonical domain that is not an Asset row. Populated only by an " @@ -407,8 +408,11 @@ class GuestScanLaunch(_DomainLaunch): tests only, which in v4.0 is every domain test. """ - target: str = Field( - description="Canonical IDNA domain without a trailing dot.", + 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"], ) From 16aa99280079a7e114907e444fa4b86043ceaeb6 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Wed, 5 Aug 2026 07:17:37 +0200 Subject: [PATCH 27/50] docs(api): clarify deletion constraints for assets and align with UUIDv7 ordering in data model --- api/openapi.json | 2 +- docs/reference/api-design-v4_0_1.md | 3 ++- docs/reference/data-model-v4_0_1.md | 5 +++-- src/nc3_testing_platform/domains/assets/router.py | 5 ++++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 35190a1..7ebdb82 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1072,7 +1072,7 @@ "assets" ], "summary": "Delete an asset", - "description": "Remove an asset from the inventory.", + "description": "Remove an asset from the inventory.\n\nAnswers `409` while scan history or discovered children reference the asset.", "operationId": "delete_asset_api_v1_assets__asset_id__delete", "security": [ { diff --git a/docs/reference/api-design-v4_0_1.md b/docs/reference/api-design-v4_0_1.md index 031ba7a..f91ab8e 100644 --- a/docs/reference/api-design-v4_0_1.md +++ b/docs/reference/api-design-v4_0_1.md @@ -12,7 +12,7 @@ - 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. -- Collection endpoints use cursor pagination and stable ordering. +- 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. @@ -146,6 +146,7 @@ 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 or discovered children reference the asset. ### 5.1 Verification diff --git a/docs/reference/data-model-v4_0_1.md b/docs/reference/data-model-v4_0_1.md index 19d754f..cce438d 100644 --- a/docs/reference/data-model-v4_0_1.md +++ b/docs/reference/data-model-v4_0_1.md @@ -9,7 +9,7 @@ Conventions: - Entity names are singular. Database identifiers use `snake_case`. -- Primary keys are UUIDs. +- 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. @@ -218,6 +218,7 @@ erDiagram - 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. ### 4.2 `domain_verification` @@ -319,7 +320,7 @@ Constraints: - 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. +- 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. diff --git a/src/nc3_testing_platform/domains/assets/router.py b/src/nc3_testing_platform/domains/assets/router.py index 7e0bad1..371c9cb 100644 --- a/src/nc3_testing_platform/domains/assets/router.py +++ b/src/nc3_testing_platform/domains/assets/router.py @@ -96,7 +96,10 @@ async def update_asset(asset_id: ResourceId, body: AssetUpdate) -> Asset: dependencies=[Authenticated], ) async def delete_asset(asset_id: ResourceId) -> Response: - """Remove an asset from the inventory.""" + """Remove an asset from the inventory. + + Answers `409` while scan history or discovered children reference the asset. + """ return Response(status_code=status.HTTP_204_NO_CONTENT) From fedfd9b6b597ce80be56ae9100398307a3267404 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Wed, 5 Aug 2026 07:18:15 +0200 Subject: [PATCH 28/50] chore(deps): add `idna>=3.18` to dependencies in `pyproject.toml` and update `uv.lock` --- pyproject.toml | 1 + uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 8dfd87b..2242c28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "fastapi[standard]>=0.139.2", + "idna>=3.18", "pydantic[email]>=2.13.4", ] diff --git a/uv.lock b/uv.lock index 8f7410b..1929533 100644 --- a/uv.lock +++ b/uv.lock @@ -474,6 +474,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "fastapi", extra = ["standard"] }, + { name = "idna" }, { name = "pydantic", extra = ["email"] }, ] @@ -488,6 +489,7 @@ dev = [ [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" }, ] From a8b5c556da4830d10d5290cfc198a9838597b4d8 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Wed, 5 Aug 2026 07:18:37 +0200 Subject: [PATCH 29/50] docs: update README to include `make lint` in development routine --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 146af81..1bbdcb0 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,11 @@ The OpenAPI 3.1 spec is generated from the FastAPI app (`nc3_testing_platform.ma ```bash 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 after any change to a router or Pydantic schema. `api/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. From f358aeb98f77f622aad22c0f3103151d8a2932c6 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Wed, 5 Aug 2026 07:31:07 +0200 Subject: [PATCH 30/50] chore(ci): add Dependabot config for GitHub Actions and update checkout action with persist-credentials disabled --- .github/dependabot.yml | 11 +++++++++++ .github/workflows/ci.yml | 2 ++ 2 files changed, 13 insertions(+) create mode 100644 .github/dependabot.yml 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 index eb97d29..e3cf4f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,8 @@ jobs: 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 From 4d9dea364ee0b037af577a7b45f7c01c636cd016 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Wed, 5 Aug 2026 15:58:56 +0200 Subject: [PATCH 31/50] fix(schemas): handle trailing dots in domain names using `removesuffix` and add test scenarios. * Labels are split on all four Unicode full stops, and a root dot survives encoding. * Removing that dot before encoding would hide an empty label and let a non-ASCII separator reappear as a trailing dot. --- src/nc3_testing_platform/core/schemas.py | 3 +- tests/test_domain_name.py | 52 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 tests/test_domain_name.py diff --git a/src/nc3_testing_platform/core/schemas.py b/src/nc3_testing_platform/core/schemas.py index ec6fe09..45aab93 100644 --- a/src/nc3_testing_platform/core/schemas.py +++ b/src/nc3_testing_platform/core/schemas.py @@ -37,9 +37,8 @@ def _parse_domain_name(value: str) -> str: Accepts Unicode or ASCII input. Raises `ValueError` for anything that does not parse as a domain. """ - candidate = value[:-1] if value.endswith(".") else value try: - canonical = idna.encode(candidate, uts46=True).decode("ascii") + 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: 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 From c083f6a33cb0bd7b93c5bd884092a1d83f9d5f69 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Wed, 5 Aug 2026 17:09:54 +0200 Subject: [PATCH 32/50] feat(api): add verification challenge to verification data model to close reverification gaps --- api/openapi.json | 192 ++++++++++-------- docs/reference/api-design-v4_0_1.md | 18 +- docs/reference/data-model-v4_0_1.md | 187 +++++++++-------- .../domains/assets/examples.py | 50 +++-- .../domains/assets/router.py | 6 +- .../domains/assets/schemas.py | 72 +++++-- 6 files changed, 302 insertions(+), 223 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 7ebdb82..83899b1 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1331,7 +1331,7 @@ "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.", + "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": [ { @@ -5487,22 +5487,14 @@ }, "DomainVerification": { "properties": { - "id": { - "type": "string", - "format": "uuid7", - "title": "Id" - }, "asset_id": { "type": "string", "format": "uuid7", "title": "Asset Id" }, "status": { - "$ref": "#/components/schemas/VerificationStatus" - }, - "requested_scope": { - "$ref": "#/components/schemas/VerificationScope", - "description": "Coverage the user asked for." + "$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": [ @@ -5513,51 +5505,7 @@ "type": "null" } ], - "description": "Coverage actually proven. Non-null only when verified. Re-requesting a wider scope does not widen this until a check succeeds, so an existing proof is never silently upgraded." - }, - "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 moves an unanswered challenge to `expired`; a verification that already succeeded 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" + "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": [ @@ -5569,53 +5517,28 @@ "type": "null" } ], - "title": "Verified 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." + "title": "Verified At", + "description": "When the proof was recorded. Non-null exactly when `verified_scope` is." }, - "failure_code": { + "challenge": { "anyOf": [ { - "type": "string" + "$ref": "#/components/schemas/VerificationChallenge" }, { "type": "null" } ], - "title": "Failure Code", - "description": "Stable namespaced reason the last check did not succeed." - }, - "updated_at": { - "type": "string", - "format": "date-time", - "title": "Updated At" + "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": [ - "id", "asset_id", - "status", - "requested_scope", - "record_name", - "verification_token", - "token_expires_at", - "requested_at", - "updated_at" + "status" ], "title": "DomainVerification", - "description": "Current state of one domain-ownership challenge.\n\nOnly the current state lives here. Attempts and transitions are recorded in\nthe audit log." + "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", @@ -8056,6 +7979,99 @@ "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": { diff --git a/docs/reference/api-design-v4_0_1.md b/docs/reference/api-design-v4_0_1.md index f91ab8e..7bd1b5d 100644 --- a/docs/reference/api-design-v4_0_1.md +++ b/docs/reference/api-design-v4_0_1.md @@ -32,8 +32,10 @@ POST /api/v1/scans/{scan_id}/claim `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). +- **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. @@ -150,13 +152,13 @@ POST /api/v1/assets/{asset_id}/feeds/{feed_id}/revoke ### 5.1 Verification -Verification is a separate resource nested under the Asset, with zero or one current state. +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. +- `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 response fully specifies the record to publish: +The `challenge` object fully specifies the record to publish: | Field | Example | Meaning | |----------------------|---------------------------|-----------------------------------------------------------------------------| @@ -167,10 +169,10 @@ The response fully specifies the record to publish: 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 without a successful check. `verified` is terminal and holds past that deadline. +- 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 .../checks` sets `last_recheck_at` whether or not the record was found. A not-found result answers `200` with the status still `pending` and a `failure_code`. -- Verification statuses are `pending`, `verified`, and `expired`. +- `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. - Current MFA assurance is read from the identity provider's session or token. - A verified domain is rechecked before an intrusive task is queued. No v4.0 test is intrusive, so nothing rechecks automatically in the MVP. diff --git a/docs/reference/data-model-v4_0_1.md b/docs/reference/data-model-v4_0_1.md index cce438d..663c155 100644 --- a/docs/reference/data-model-v4_0_1.md +++ b/docs/reference/data-model-v4_0_1.md @@ -56,13 +56,12 @@ Conventions: ### 2.2 Assets and verification -| Used by | PostgreSQL type | Values | -|-----------------------|-----------------------|----------------------------------| -| `asset` | `asset_type` | `domain` | -| `asset` | `asset_origin` | `added`, `discovered` | -| `domain_verification` | `verification_status` | `pending`, `verified` | -| `domain_verification` | `verification_scope` | `exact`, `zone` | -| `domain_verification` | `dns_record_type` | `TXT` | +| 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. @@ -203,66 +202,80 @@ erDiagram ### 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 | +| 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 | +| `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` | -| `status` | `verification_status` | Not null | -| `requested_scope` | `verification_scope` | Not null | -| `verified_scope` | `verification_scope` | Nullable | -| `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 | -| `verified_at` | timestamptz | Nullable | -| `last_recheck_at` | timestamptz | Nullable | -| `failure_code` | text | Nullable | -| `updated_at` | timestamptz | Not null | +| 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`. -- `verified_scope` is non-null only when `status = verified`. -- Changing `requested_scope` does not change `verified_scope` until verification succeeds. +- 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. - `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 verification in `pending` whose `token_expires_at` has passed is expired. Expiry is derived from that timestamp, so `verification_status` carries no `expired` value. The API exposes `expired` as a computed status. -- `verified` is terminal. Reaching `token_expires_at` does not change a verification that already succeeded. -- Replacing the token while `status = verified` is rejected. Re-proving ownership or widening scope starts a new challenge, and `verified_scope` holds until that challenge succeeds. +- 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. -- This table stores the current state only. Verification attempts and status changes are recorded in `audit_event`. +- 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: requests + APP_USER o|--o{ DOMAIN_VERIFICATION_CHALLENGE: requests ``` ## 5. Statements and responses @@ -641,15 +654,15 @@ erDiagram ### 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 | +| 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: @@ -745,8 +758,8 @@ erDiagram 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 its status is `verified` 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` and `audit_event`, not in a separate authorization table. +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`. @@ -762,40 +775,38 @@ erDiagram 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". Cross-row and cross-table rules stay in §13. -| Table | Constraint | Enforces | -|---|---|---| -| `organization_invitation` | `(accepted_at IS NULL) = (accepted_by_user_id IS NULL)` | acceptance records the actor and the time together | -| `asset` | `parent_asset_id IS NULL OR origin = 'discovered'` | only discovery produces child assets | -| `domain_verification` | `(status = 'verified') = (verified_scope IS NOT NULL)` | proven coverage exists exactly on the verified state | -| `domain_verification` | `(status = 'verified') = (verified_at IS NOT NULL)` | the proof timestamp exists exactly on the verified state | -| `domain_verification` | `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_at IS NULL) = (claimed_by_user_id IS NULL)` | claiming records the actor and the time together | -| `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 NULL OR module = 'file'` | only File tasks reference an upload | -| `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 | +| Table | Constraint | Enforces | +|---------------------------------|------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------| +| `organization_invitation` | `(accepted_at IS NULL) = (accepted_by_user_id IS NULL)` | acceptance records the actor and the time together | +| `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_at IS NULL) = (claimed_by_user_id IS NULL)` | claiming records the actor and the time together | +| `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 NULL OR module = 'file'` | only File tasks reference an upload | +| `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: diff --git a/src/nc3_testing_platform/domains/assets/examples.py b/src/nc3_testing_platform/domains/assets/examples.py index 58008c1..fa7257c 100644 --- a/src/nc3_testing_platform/domains/assets/examples.py +++ b/src/nc3_testing_platform/domains/assets/examples.py @@ -19,6 +19,7 @@ AssetFeed, AssetFeedCreated, DomainVerification, + VerificationChallenge, ) from nc3_testing_platform.domains.scans.examples import ( ASSET_ID, @@ -30,7 +31,7 @@ _T1 = datetime(2026, 7, 31, 9, 1, 12, tzinfo=UTC) _SUBDOMAIN_ASSET_ID = UUID("019ee1a3-0011-7a22-8b33-4c44d5e66f77") -_VERIFICATION_ID = UUID("019ee1a3-1122-7b33-9c44-5d55e6f77a88") +_CHALLENGE_ID = UUID("019ee1a3-1122-7b33-9c44-5d55e6f77a88") _FEED_ID = UUID("019ee1a3-2233-7c44-ad55-6e66f7a88b99") @@ -68,31 +69,48 @@ def sample_discovered_asset() -> Asset: ) +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. - - `checked` marks a state that a lookup has just produced, which is what sets - `last_recheck_at`. - """ + """A zone-scoped verification in the given state.""" verified = status == VerificationStatus.VERIFIED return DomainVerification( - id=_VERIFICATION_ID, asset_id=ASSET_ID, status=status, - requested_scope=VerificationScope.ZONE, verified_scope=VerificationScope.ZONE if verified else None, - verification_token="verify-4f7a2c9e1b8d3056", - record_name=verification_record_name("example.lu"), - token_expires_at=_T0 + VERIFICATION_TOKEN_TTL, - requested_by_user_id=USER_ID, - requested_at=_T0, verified_at=_T0 if verified else None, - last_recheck_at=_T1 if checked else None, - failure_code=None if verified else "dns.txt_record_not_found", - updated_at=_T1, + 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(), ) diff --git a/src/nc3_testing_platform/domains/assets/router.py b/src/nc3_testing_platform/domains/assets/router.py index 371c9cb..948929b 100644 --- a/src/nc3_testing_platform/domains/assets/router.py +++ b/src/nc3_testing_platform/domains/assets/router.py @@ -139,8 +139,12 @@ async def create_verification( 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_verification(status=VerificationStatus.PENDING) + return examples.sample_reverification() @router.post( diff --git a/src/nc3_testing_platform/domains/assets/schemas.py b/src/nc3_testing_platform/domains/assets/schemas.py index fd5e5d1..a1506a7 100644 --- a/src/nc3_testing_platform/domains/assets/schemas.py +++ b/src/nc3_testing_platform/domains/assets/schemas.py @@ -3,9 +3,9 @@ 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 with zero or one current state, 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. +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 @@ -79,26 +79,16 @@ class AssetUpdate(BaseModel): regression_alerts_enabled: bool -class DomainVerification(BaseSchema): - """Current state of one domain-ownership challenge. +class VerificationChallenge(BaseSchema): + """A DNS record to publish to prove control of the domain. - Only the current state lives here. Attempts and transitions are recorded in - the audit log. + 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 - asset_id: ResourceId - status: VerificationStatus requested_scope: VerificationScope = Field( - description="Coverage the user asked for." - ) - verified_scope: VerificationScope | None = Field( - default=None, - description=( - "Coverage actually proven. Non-null only when verified. Re-requesting a " - "wider scope does not widen this until a check succeeds, so an existing " - "proof is never silently upgraded." - ), + description="Coverage this challenge proves if it succeeds." ) record_type: DnsRecordType = Field( default=DnsRecordType.TXT, @@ -122,13 +112,12 @@ class DomainVerification(BaseSchema): token_expires_at: Timestamp = Field( description=( "When the challenge stops being answerable. Seven days from issue by " - "default. Reaching it moves an unanswered challenge to `expired`; a " - "verification that already succeeded is unaffected." + "default. Reaching it retires the challenge; a proof already recorded is " + "unaffected." ) ) requested_by_user_id: ResourceId | None = None requested_at: Timestamp - verified_at: Timestamp | None = None last_recheck_at: Timestamp | None = Field( default=None, description=( @@ -142,7 +131,46 @@ class DomainVerification(BaseSchema): default=None, description="Stable namespaced reason the last check did not succeed.", ) - updated_at: Timestamp + + +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): From ae4c841f98db9b62b0d9df8bfa88c51e70facb93 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Wed, 5 Aug 2026 17:25:20 +0200 Subject: [PATCH 33/50] docs(data-model): clarify constraint handling for user erasure and acceptance/claim relationships --- docs/reference/data-model-v4_0_1.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/reference/data-model-v4_0_1.md b/docs/reference/data-model-v4_0_1.md index 663c155..872d527 100644 --- a/docs/reference/data-model-v4_0_1.md +++ b/docs/reference/data-model-v4_0_1.md @@ -174,7 +174,7 @@ 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. +- 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 @@ -430,7 +430,7 @@ Constraints: - `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. -- When `claimed_by_user_id` is set: `organization_id` is the claimed user's organization, and `claimed_at` is not null. For a file ScanJob, the claim also sets `file_upload.organization_id` and `file_upload.uploaded_by_user_id`. +- 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` @@ -773,11 +773,11 @@ erDiagram ## 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". Cross-row and cross-table rules stay in §13. +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_at IS NULL) = (accepted_by_user_id IS NULL)` | acceptance records the actor and the time together | +| `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 | @@ -789,7 +789,8 @@ Every constraint below enforces an invariant already stated in the table section | `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_at IS NULL) = (claimed_by_user_id IS NULL)` | claiming records the actor and the time together | +| `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 | From aba221defa62ab8ae6271ca2b21bc92da03170fe Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Wed, 5 Aug 2026 18:55:13 +0200 Subject: [PATCH 34/50] chore(deps): add `httpx2>=2.9.1`, `httpcore2`, and `truststore` to dependencies and update `uv.lock` --- pyproject.toml | 1 + uv.lock | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2242c28..ddb58aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "fastapi[standard]>=0.139.2", + "httpx2>=2.9.1", "idna>=3.18", "pydantic[email]>=2.13.4", ] diff --git a/uv.lock b/uv.lock index 1929533..990cffc 100644 --- a/uv.lock +++ b/uv.lock @@ -253,6 +253,19 @@ 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" @@ -297,6 +310,21 @@ 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" @@ -474,6 +502,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "fastapi", extra = ["standard"] }, + { name = "httpx2" }, { name = "idna" }, { name = "pydantic", extra = ["email"] }, ] @@ -489,6 +518,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.139.2" }, + { name = "httpx2", specifier = ">=2.9.1" }, { name = "idna", specifier = ">=3.18" }, { name = "pydantic", extras = ["email"], specifier = ">=2.13.4" }, ] @@ -1023,6 +1053,15 @@ 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" From 9bed78329406aa89e0bb794b9ea0912246648b49 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Wed, 5 Aug 2026 18:57:31 +0200 Subject: [PATCH 35/50] feat(errors): add handler for unhandled exceptions and enforce RFC 9457 compliance in error responses - Introduced `_unhandled_exception_handler` to standardize responses for uncaught exceptions. - Updated `register_exception_handlers` to include the new handler. - Added tests to validate RFC 9457 compliance and ensure exception details do not leak to clients. --- src/nc3_testing_platform/core/errors.py | 19 +++++++- tests/test_error_contract.py | 59 +++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 tests/test_error_contract.py diff --git a/src/nc3_testing_platform/core/errors.py b/src/nc3_testing_platform/core/errors.py index d8cd3fa..54a3130 100644 --- a/src/nc3_testing_platform/core/errors.py +++ b/src/nc3_testing_platform/core/errors.py @@ -108,11 +108,28 @@ async def _validation_exception_handler( ) +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 errors through the problem+json handlers.""" + """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]: 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." From 922eff130ff2af541911382c5c14b72a0e734453 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Wed, 5 Aug 2026 18:58:04 +0200 Subject: [PATCH 36/50] test(api): add tests for case insensitivity and rejection of invalid media types in `POST /scans` - Introduced tests to ensure JSON `Content-Type` handling is case-insensitive. - Added validation for rejecting unsupported media types as per RFC standards. --- .../domains/scans/dependencies.py | 2 +- tests/test_launch_dispatch.py | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 tests/test_launch_dispatch.py diff --git a/src/nc3_testing_platform/domains/scans/dependencies.py b/src/nc3_testing_platform/domains/scans/dependencies.py index 975f577..60dedb6 100644 --- a/src/nc3_testing_platform/domains/scans/dependencies.py +++ b/src/nc3_testing_platform/domains/scans/dependencies.py @@ -104,7 +104,7 @@ async def resolve_launch_body( 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() + media_type = (request.headers.get("content-type") or "").split(";")[0].strip().lower() authenticated = bool(oidc or key) if media_type == MULTIPART_MEDIA_TYPE: 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 From 637f1e8001c66da3240825a7541999b19165678d Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Wed, 5 Aug 2026 18:59:10 +0200 Subject: [PATCH 37/50] chore(deps): move `httpx2` from dependencies to dev-dependencies and update `uv.lock` --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ddb58aa..27abdad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,6 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "fastapi[standard]>=0.139.2", - "httpx2>=2.9.1", "idna>=3.18", "pydantic[email]>=2.13.4", ] @@ -16,6 +15,7 @@ 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", diff --git a/uv.lock b/uv.lock index 990cffc..0f8c7d8 100644 --- a/uv.lock +++ b/uv.lock @@ -502,13 +502,13 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "fastapi", extra = ["standard"] }, - { name = "httpx2" }, { name = "idna" }, { name = "pydantic", extra = ["email"] }, ] [package.dev-dependencies] dev = [ + { name = "httpx2" }, { name = "openapi-spec-validator" }, { name = "pyright" }, { name = "pytest" }, @@ -518,13 +518,13 @@ dev = [ [package.metadata] requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.139.2" }, - { name = "httpx2", specifier = ">=2.9.1" }, { 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" }, From 4f3991c9c052f308de59d7ec751ef46c6329b937 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Fri, 7 Aug 2026 15:12:51 +0200 Subject: [PATCH 38/50] docs(api): remove confusion over API key revocation and password changes --- api/openapi.json | 2 +- docs/reference/api-design-v4_0_1.md | 2 +- docs/reference/data-model-v4_0_1.md | 1 - src/nc3_testing_platform/domains/api_keys/router.py | 3 +-- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 83899b1..74631a5 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -4195,7 +4195,7 @@ "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. Revocation also happens outside this endpoint: changing a password\nrevokes that user's keys, and erasing an account revokes and deletes them.", + "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": [ { diff --git a/docs/reference/api-design-v4_0_1.md b/docs/reference/api-design-v4_0_1.md index 7bd1b5d..950aa6f 100644 --- a/docs/reference/api-design-v4_0_1.md +++ b/docs/reference/api-design-v4_0_1.md @@ -338,7 +338,7 @@ POST /api/v1/api-keys/{key_id}/revoke - 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. - Every key-management operation consumes current MFA assurance. -- Revocation also happens outside this resource: a password change revokes that user's keys, and account erasure revokes and deletes them. +- 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 diff --git a/docs/reference/data-model-v4_0_1.md b/docs/reference/data-model-v4_0_1.md index 872d527..db8e166 100644 --- a/docs/reference/data-model-v4_0_1.md +++ b/docs/reference/data-model-v4_0_1.md @@ -591,7 +591,6 @@ Rules: - The plaintext key is never stored. - Key-management actions consume current MFA assurance from the identity provider. -- Password changes revoke user-owned keys. - Account erasure revokes and deletes user-owned keys. The revocation stays represented by the audit event. ```mermaid diff --git a/src/nc3_testing_platform/domains/api_keys/router.py b/src/nc3_testing_platform/domains/api_keys/router.py index d230cd4..4cb1a06 100644 --- a/src/nc3_testing_platform/domains/api_keys/router.py +++ b/src/nc3_testing_platform/domains/api_keys/router.py @@ -91,7 +91,6 @@ 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. Revocation also happens outside this endpoint: changing a password - revokes that user's keys, and erasing an account revokes and deletes them. + point. Erasing an account also revokes and deletes that user's keys. """ return _sample_key(revoked=True) From c998885030d3b26daf22fad615686a00e2ef2e17 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Fri, 7 Aug 2026 15:46:06 +0200 Subject: [PATCH 39/50] docs(api): clarify pagination behavior for collection endpoints ("Some endpoints...") --- docs/reference/api-design-v4_0_1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/api-design-v4_0_1.md b/docs/reference/api-design-v4_0_1.md index 950aa6f..c7c2f43 100644 --- a/docs/reference/api-design-v4_0_1.md +++ b/docs/reference/api-design-v4_0_1.md @@ -12,7 +12,7 @@ - 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. -- 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). +- 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. From 1ccfc60cad676ffca950afc250b5d22c3f467b4b Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Fri, 7 Aug 2026 15:46:58 +0200 Subject: [PATCH 40/50] docs(api): clarify `POST /reports` source field and domain target handling to specify `source_scan_job_id` or `source_scan_task_id`. --- docs/reference/api-design-v4_0_1.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/reference/api-design-v4_0_1.md b/docs/reference/api-design-v4_0_1.md index c7c2f43..7d10a49 100644 --- a/docs/reference/api-design-v4_0_1.md +++ b/docs/reference/api-design-v4_0_1.md @@ -35,7 +35,9 @@ POST /api/v1/scans/{scan_id}/claim - **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. @@ -211,7 +213,7 @@ GET /api/v1/feeds/{token} ### 7.1 Reports -`POST /reports` accepts exactly one source: `scan_job_id` or `scan_task_id`. It renders synchronously and returns the document in the response body, with the media type matching the requested `format`: +`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 | |----------|---------------------------------------------------------------------------| From e9417b5247eed3494bdd1572afa44d0c819ac349 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Fri, 7 Aug 2026 16:01:22 +0200 Subject: [PATCH 41/50] docs(api): tighten semantics for `DELETE` behavior and mention MFA requirement in verification flow --- docs/reference/api-design-v4_0_1.md | 3 ++- docs/reference/data-model-v4_0_1.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/reference/api-design-v4_0_1.md b/docs/reference/api-design-v4_0_1.md index 7d10a49..1c5a627 100644 --- a/docs/reference/api-design-v4_0_1.md +++ b/docs/reference/api-design-v4_0_1.md @@ -150,7 +150,7 @@ 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 or discovered children reference the asset. +- `DELETE` answers `409` while scan history, discovered children, a verification, or a feed reference the asset. ### 5.1 Verification @@ -175,6 +175,7 @@ Rules: - `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 .../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. - A verified domain is rechecked before an intrusive task is queued. No v4.0 test is intrusive, so nothing rechecks automatically in the MVP. diff --git a/docs/reference/data-model-v4_0_1.md b/docs/reference/data-model-v4_0_1.md index db8e166..961f0ad 100644 --- a/docs/reference/data-model-v4_0_1.md +++ b/docs/reference/data-model-v4_0_1.md @@ -533,7 +533,7 @@ Constraints: | `affected_resource` | text | Nullable | | `remediation` | text | Nullable | | `evidence` | JSONB | Nullable | -| `external_references` | JSONB | Not null; default | +| `external_references` | JSONB | Not null; default `[]` | - Index: (`scan_result_id`, `check_id`). - `finding.status` persists the derived historical-comparison classification with the result projection. From 6d636f9c43e1efaa779930997d83cfa820890776 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Fri, 7 Aug 2026 16:05:03 +0200 Subject: [PATCH 42/50] docs(data-model): clarify constraint for file task upload targeting --- docs/reference/data-model-v4_0_1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/data-model-v4_0_1.md b/docs/reference/data-model-v4_0_1.md index 961f0ad..c5359d6 100644 --- a/docs/reference/data-model-v4_0_1.md +++ b/docs/reference/data-model-v4_0_1.md @@ -798,7 +798,7 @@ Every constraint below enforces an invariant already stated in the table section | `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 NULL OR module = 'file'` | only File tasks reference an upload | +| `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 | From 3bb75dafae7825315d83426256f589a7978e8598 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Fri, 7 Aug 2026 16:21:16 +0200 Subject: [PATCH 43/50] feat(auth): add `OptionallyAuthenticated` dependency for conditional authentication support on operations - Implemented `OptionallyAuthenticated` in `core.security` for operations accommodating both authentication and claim tokens. - Applied the new dependency to scan-related endpoints for enhanced flexibility in access control. - Added extra OpenAPI metadata for anonymous access representation. - Introduced validation for `technical_view` restriction in `ReportRequest` schema. --- api/openapi.json | 27 +++++++++++++++++ src/nc3_testing_platform/core/security.py | 14 +++++++++ .../domains/reports/schemas.py | 6 ++++ .../domains/scans/repository.py | 3 +- .../domains/scans/router.py | 7 +++++ tests/test_openapi_export.py | 29 +++++++++++++++++++ 6 files changed, 84 insertions(+), 2 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 74631a5..534d323 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -210,6 +210,15 @@ "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", @@ -355,6 +364,15 @@ "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", @@ -431,6 +449,15 @@ "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", diff --git a/src/nc3_testing_platform/core/security.py b/src/nc3_testing_platform/core/security.py index 8b51593..1d6ab01 100644 --- a/src/nc3_testing_platform/core/security.py +++ b/src/nc3_testing_platform/core/security.py @@ -72,6 +72,20 @@ def require_authentication(oidc: OidcAuth, key: ApiKeyAuth) -> None: # Attach as `dependencies=[Authenticated]` on an operation. Authenticated = Depends(require_authentication) + +def read_optional_credentials(oidc: OidcAuth, key: ApiKeyAuth) -> None: + """Declares both credentials without requiring either. + + 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]]] = [{}] diff --git a/src/nc3_testing_platform/domains/reports/schemas.py b/src/nc3_testing_platform/domains/reports/schemas.py index f64762c..c1400f8 100644 --- a/src/nc3_testing_platform/domains/reports/schemas.py +++ b/src/nc3_testing_platform/domains/reports/schemas.py @@ -46,6 +46,12 @@ def _exactly_one_source(self) -> "ReportRequest": ) 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. diff --git a/src/nc3_testing_platform/domains/scans/repository.py b/src/nc3_testing_platform/domains/scans/repository.py index 86d582c..f0e11bd 100644 --- a/src/nc3_testing_platform/domains/scans/repository.py +++ b/src/nc3_testing_platform/domains/scans/repository.py @@ -10,8 +10,7 @@ # 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, per -# docs/architecture/scan-launch-and-upload-handling.md §1. +# 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. diff --git a/src/nc3_testing_platform/domains/scans/router.py b/src/nc3_testing_platform/domains/scans/router.py index 074cb05..89cc337 100644 --- a/src/nc3_testing_platform/domains/scans/router.py +++ b/src/nc3_testing_platform/domains/scans/router.py @@ -23,6 +23,7 @@ from nc3_testing_platform.core.security import ( ANONYMOUS_ALTERNATIVE, Authenticated, + OptionallyAuthenticated, rate_limited, ) from nc3_testing_platform.domains.scans import examples @@ -171,6 +172,8 @@ async def list_scans(page: CursorPage) -> Page[ScanJob]: "/{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 @@ -189,6 +192,8 @@ async def get_scan( "/{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 @@ -206,6 +211,8 @@ async def get_scan_results( 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 diff --git a/tests/test_openapi_export.py b/tests/test_openapi_export.py index 188b767..81818fa 100644 --- a/tests/test_openapi_export.py +++ b/tests/test_openapi_export.py @@ -87,6 +87,35 @@ def test_anonymous_operations_are_exactly_the_documented_set( 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(): From 528b10ab70df6704f88131d734c06dd038184a0b Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Fri, 7 Aug 2026 16:21:24 +0200 Subject: [PATCH 44/50] docs(api): clarify alternative credential use for token-based operations --- docs/reference/api-design-v4_0_1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/api-design-v4_0_1.md b/docs/reference/api-design-v4_0_1.md index 1c5a627..7a9cf9b 100644 --- a/docs/reference/api-design-v4_0_1.md +++ b/docs/reference/api-design-v4_0_1.md @@ -70,7 +70,7 @@ Server rules: 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 no authentication: +**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} From f93b6b249aae5047ea176419d2cc2d9ce8855ee7 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Fri, 7 Aug 2026 16:40:10 +0200 Subject: [PATCH 45/50] docs(security): clarify behavior of `read_optional_credentials` for guest and owner access scenarios --- src/nc3_testing_platform/core/security.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/nc3_testing_platform/core/security.py b/src/nc3_testing_platform/core/security.py index 1d6ab01..6181742 100644 --- a/src/nc3_testing_platform/core/security.py +++ b/src/nc3_testing_platform/core/security.py @@ -76,8 +76,11 @@ def require_authentication(oidc: OidcAuth, key: ApiKeyAuth) -> None: def read_optional_credentials(oidc: OidcAuth, key: ApiKeyAuth) -> None: """Declares both credentials without requiring either. - Belongs on an operation that an owner reaches with a credential and a guest - reaches with a claim token. + The parameter list is the entire effect: + FastAPI reads the two credential dependencies and publishes both schemes into the operation's `security`. + The empty body is correct — the operation 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. """ From 8702c3384045e872bc351947cedd2ec66c54c2a5 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Fri, 7 Aug 2026 16:41:35 +0200 Subject: [PATCH 46/50] docs(api): clarify `DELETE /assets/{asset_id}` constraints for additional reference types --- src/nc3_testing_platform/domains/assets/router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nc3_testing_platform/domains/assets/router.py b/src/nc3_testing_platform/domains/assets/router.py index 948929b..dc71487 100644 --- a/src/nc3_testing_platform/domains/assets/router.py +++ b/src/nc3_testing_platform/domains/assets/router.py @@ -98,7 +98,7 @@ async def update_asset(asset_id: ResourceId, body: AssetUpdate) -> Asset: async def delete_asset(asset_id: ResourceId) -> Response: """Remove an asset from the inventory. - Answers `409` while scan history or discovered children reference the asset. + 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) From 09fdd80bb823ca0c321cdab8b02302d32d54f8c1 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Fri, 7 Aug 2026 16:41:48 +0200 Subject: [PATCH 47/50] docs(api): specify `DELETE` constraint for schedule references in assets --- docs/reference/api-design-v4_0_1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/api-design-v4_0_1.md b/docs/reference/api-design-v4_0_1.md index 7a9cf9b..ccfc018 100644 --- a/docs/reference/api-design-v4_0_1.md +++ b/docs/reference/api-design-v4_0_1.md @@ -150,7 +150,7 @@ 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, or a feed reference the asset. +- `DELETE` answers `409` while scan history, discovered children, a verification, a schedule, or a feed reference the asset. ### 5.1 Verification From 541976a24448a6366d1ef52c054ee8cd9afb5b90 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Fri, 7 Aug 2026 16:42:13 +0200 Subject: [PATCH 48/50] docs(api): refresh openapi.json --- api/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/openapi.json b/api/openapi.json index 534d323..36da896 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1099,7 +1099,7 @@ "assets" ], "summary": "Delete an asset", - "description": "Remove an asset from the inventory.\n\nAnswers `409` while scan history or discovered children reference the 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": [ { From ad03e541c6420ca489f8ebf4a1fd5a6be3d50b80 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Fri, 7 Aug 2026 17:55:20 +0200 Subject: [PATCH 49/50] docs(api, data-model): clarify challenge replacement behavior and MFA assurance constraints --- docs/reference/api-design-v4_0_1.md | 5 +++-- docs/reference/data-model-v4_0_1.md | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/api-design-v4_0_1.md b/docs/reference/api-design-v4_0_1.md index ccfc018..68c1b40 100644 --- a/docs/reference/api-design-v4_0_1.md +++ b/docs/reference/api-design-v4_0_1.md @@ -173,10 +173,11 @@ 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. +- 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 @@ -340,7 +341,7 @@ POST /api/v1/api-keys/{key_id}/revoke - `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. -- Every key-management operation consumes current MFA assurance. +- 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`. diff --git a/docs/reference/data-model-v4_0_1.md b/docs/reference/data-model-v4_0_1.md index c5359d6..eb117c2 100644 --- a/docs/reference/data-model-v4_0_1.md +++ b/docs/reference/data-model-v4_0_1.md @@ -260,6 +260,7 @@ Constraints: 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. From 032fd236e49efd52b839493d02dc4945f7322105 Mon Sep 17 00:00:00 2001 From: Nicky Mezzina Date: Fri, 7 Aug 2026 17:56:53 +0200 Subject: [PATCH 50/50] feat(auth): enforce MFA requirement for API key management and verification flows; also clarified enum documentation to specify computed vs. stored attributes. --- api/openapi.json | 11 +------- src/nc3_testing_platform/core/enums.py | 3 +- src/nc3_testing_platform/core/security.py | 28 +++++++++++++++---- .../domains/api_keys/router.py | 6 ++-- .../domains/assets/router.py | 4 +-- 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 36da896..767965e 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1363,9 +1363,6 @@ "security": [ { "OpenIdConnect": [] - }, - { - "ApiKey": [] } ], "parameters": [ @@ -4157,9 +4154,6 @@ "security": [ { "OpenIdConnect": [] - }, - { - "ApiKey": [] } ], "requestBody": { @@ -4227,9 +4221,6 @@ "security": [ { "OpenIdConnect": [] - }, - { - "ApiKey": [] } ], "parameters": [ @@ -8347,7 +8338,7 @@ }, "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`. Key management consumes current MFA assurance.", + "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/src/nc3_testing_platform/core/enums.py b/src/nc3_testing_platform/core/enums.py index d9aaf9c..4b112f6 100644 --- a/src/nc3_testing_platform/core/enums.py +++ b/src/nc3_testing_platform/core/enums.py @@ -1,6 +1,7 @@ """Closed enumerations of the v4 contract. -Every enum mirrors a PostgreSQL enum type in the data model. +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 diff --git a/src/nc3_testing_platform/core/security.py b/src/nc3_testing_platform/core/security.py index 6181742..d82b297 100644 --- a/src/nc3_testing_platform/core/security.py +++ b/src/nc3_testing_platform/core/security.py @@ -47,7 +47,8 @@ description=( "Platform API key: `Authorization: Bearer `. Scopes are `read_only` " "and `full_scan`. A scan launched with a key is recorded with " - "`source = api`. Key management consumes current MFA assurance." + "`source = api`. Creating or revoking a key requires an OpenID Connect " + "token with current MFA assurance." ), ) @@ -55,14 +56,14 @@ ApiKeyAuth = Annotated[str | None, Depends(api_key)] -def require_authentication(oidc: OidcAuth, key: ApiKeyAuth) -> None: +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 or key): + 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.", @@ -73,12 +74,29 @@ def require_authentication(oidc: OidcAuth, key: ApiKeyAuth) -> None: Authenticated = Depends(require_authentication) -def read_optional_credentials(oidc: OidcAuth, key: ApiKeyAuth) -> None: +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 empty body is correct — the operation applies no gate, because a guest presents a claim token in place of a credential. + 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. """ diff --git a/src/nc3_testing_platform/domains/api_keys/router.py b/src/nc3_testing_platform/domains/api_keys/router.py index 4cb1a06..c4b0716 100644 --- a/src/nc3_testing_platform/domains/api_keys/router.py +++ b/src/nc3_testing_platform/domains/api_keys/router.py @@ -13,7 +13,7 @@ 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.core.security import Authenticated, MfaGated from nc3_testing_platform.domains.api_keys.schemas import ( ApiKey, ApiKeyCreate, @@ -67,7 +67,7 @@ async def list_api_keys(page: CursorPage) -> Page[ApiKey]: status_code=status.HTTP_201_CREATED, summary="Create an API key", responses=problem_responses(401, 403, 422), - dependencies=[Authenticated], + dependencies=[MfaGated], ) async def create_api_key(body: ApiKeyCreate) -> ApiKeyCreated: """Issue a key and return its secret once. @@ -85,7 +85,7 @@ async def create_api_key(body: ApiKeyCreate) -> ApiKeyCreated: "/{key_id}/revoke", summary="Revoke an API key", responses=problem_responses(401, 403, 404, 409), - dependencies=[Authenticated], + dependencies=[MfaGated], ) async def revoke_api_key(key_id: ResourceId, body: ApiKeyRevoke) -> ApiKey: """Stop a key working while keeping its row. diff --git a/src/nc3_testing_platform/domains/assets/router.py b/src/nc3_testing_platform/domains/assets/router.py index dc71487..24075d7 100644 --- a/src/nc3_testing_platform/domains/assets/router.py +++ b/src/nc3_testing_platform/domains/assets/router.py @@ -11,7 +11,7 @@ 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.core.security import Authenticated, MfaGated from nc3_testing_platform.domains.assets import examples from nc3_testing_platform.domains.assets.schemas import ( Asset, @@ -130,7 +130,7 @@ async def get_verification(asset_id: ResourceId) -> DomainVerification: status_code=status.HTTP_201_CREATED, summary="Start a verification challenge", responses=problem_responses(401, 403, 404, 409, 422), - dependencies=[Authenticated], + dependencies=[MfaGated], ) async def create_verification( asset_id: ResourceId, body: VerificationCreate