From 660b1520e20ba2abc895d6b8f9ab286f53ca72d0 Mon Sep 17 00:00:00 2001 From: t0kubetsu Date: Mon, 10 Aug 2026 15:16:43 +0200 Subject: [PATCH 1/4] feat(contract): statement-acceptance readback and step-up problem type (Taiga #199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additive contract changes from the PR #1 review closure: - GET /statement-responses: the caller's receipts, each restating the statement_key and exact version answered, so the UI can decide whether to show the ToS/AUP prompt without joining against retired statement versions (review comment A22). StatementResponseReceipt gains statement_key and version, which also enriches the POST response. - Step-up problem type: the platform's first minted RFC 9457 type URI, https://testing.nc3.lu/problems/step-up-required, carried by the 403 of the three operations gated on current MFA assurance (API-key create and revoke, verification start). Distinguishes step-up — remedied by fresh authentication with an MFA method — from a plain forbidden (A20 companion). The OpenAPI relabel pass now preserves keys pre-declared under problem+json so the example survives export. api-design §1/§5.1/§14 updated; openapi.json re-exported; smoke case added. --- api/openapi.json | 108 +++++++++++++++--- docs/reference/api-design-v4_0_1.md | 5 +- src/nc3_testing_platform/core/errors.py | 46 +++++++- .../domains/api_keys/router.py | 7 +- .../domains/assets/router.py | 4 +- .../domains/statements/router.py | 32 +++++- .../domains/statements/schemas.py | 7 ++ tests/test_smoke_surface.py | 1 + 8 files changed, 179 insertions(+), 31 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index d0badfb..5a1c421 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1427,8 +1427,8 @@ } } }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not Found", "content": { "application/problem+json": { "schema": { @@ -1437,8 +1437,8 @@ } } }, - "404": { - "description": "Not Found", + "409": { + "description": "Conflict", "content": { "application/problem+json": { "schema": { @@ -1447,8 +1447,8 @@ } } }, - "409": { - "description": "Conflict", + "422": { + "description": "Unprocessable Content", "content": { "application/problem+json": { "schema": { @@ -1457,12 +1457,18 @@ } } }, - "422": { - "description": "Unprocessable Content", + "403": { + "description": "Forbidden. When the operation requires current MFA assurance that the presented token does not carry, `type` is `https://testing.nc3.lu/problems/step-up-required` and the remedy is a fresh authentication with an MFA method.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetail" + }, + "example": { + "type": "https://testing.nc3.lu/problems/step-up-required", + "title": "Step-up authentication required", + "status": 403, + "detail": "This operation requires a recent multi-factor authentication event." } } } @@ -4230,8 +4236,8 @@ } } }, - "403": { - "description": "Forbidden", + "422": { + "description": "Unprocessable Content", "content": { "application/problem+json": { "schema": { @@ -4240,12 +4246,18 @@ } } }, - "422": { - "description": "Unprocessable Content", + "403": { + "description": "Forbidden. When the operation requires current MFA assurance that the presented token does not carry, `type` is `https://testing.nc3.lu/problems/step-up-required` and the remedy is a fresh authentication with an MFA method.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetail" + }, + "example": { + "type": "https://testing.nc3.lu/problems/step-up-required", + "title": "Step-up authentication required", + "status": 403, + "detail": "This operation requires a recent multi-factor authentication event." } } } @@ -4309,8 +4321,8 @@ } } }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not Found", "content": { "application/problem+json": { "schema": { @@ -4319,8 +4331,8 @@ } } }, - "404": { - "description": "Not Found", + "409": { + "description": "Conflict", "content": { "application/problem+json": { "schema": { @@ -4329,12 +4341,18 @@ } } }, - "409": { - "description": "Conflict", + "403": { + "description": "Forbidden. When the operation requires current MFA assurance that the presented token does not carry, `type` is `https://testing.nc3.lu/problems/step-up-required` and the remedy is a fresh authentication with an MFA method.", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetail" + }, + "example": { + "type": "https://testing.nc3.lu/problems/step-up-required", + "title": "Step-up authentication required", + "status": 403, + "detail": "This operation requires a recent multi-factor authentication event." } } } @@ -4389,6 +4407,48 @@ } }, "/api/v1/statement-responses": { + "get": { + "tags": [ + "statements" + ], + "summary": "List the caller's statement responses", + "description": "Receipts the caller has recorded, account-level and context-bound alike.\n\nThe readback behind the acceptance prompt: a statement in force whose current\nversion has no receipt here is one the caller has yet to answer.", + "operationId": "list_statement_responses_api_v1_statement_responses_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/StatementResponseReceipt" + }, + "type": "array", + "title": "Response List Statement Responses Api V1 Statement Responses Get" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + }, + "security": [ + { + "OpenIdConnect": [] + }, + { + "ApiKey": [] + } + ] + }, "post": { "tags": [ "statements" @@ -7964,6 +8024,16 @@ "format": "uuid7", "title": "Statement Id" }, + "statement_key": { + "type": "string", + "title": "Statement Key", + "description": "Statement identifier, restated so a receipt readback needs no join against retired statement versions." + }, + "version": { + "type": "string", + "title": "Version", + "description": "Exact version answered." + }, "responded_at": { "type": "string", "format": "date-time", @@ -7999,6 +8069,8 @@ "required": [ "id", "statement_id", + "statement_key", + "version", "responded_at" ], "title": "StatementResponseReceipt", diff --git a/docs/reference/api-design-v4_0_1.md b/docs/reference/api-design-v4_0_1.md index a03007d..68632b1 100644 --- a/docs/reference/api-design-v4_0_1.md +++ b/docs/reference/api-design-v4_0_1.md @@ -11,7 +11,7 @@ - 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. +- Errors use the RFC 9457 Problem Details envelope. A problem `type` URI is minted only where a client must discriminate between remedies for the same status code (§5.1's step-up type); every other error carries `about:blank`. - 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. @@ -179,6 +179,7 @@ Rules: - `POST .../verification` requires current MFA assurance. - Current MFA assurance is read from the identity provider's session or token. An operation that requires it therefore declares only the OpenID Connect scheme: a platform API key carries no assurance. - Assurance is the `amr` claim carrying an MFA method, current while the authentication event is within the configured `max_age`. The platform reads `amr` rather than `acr`: `amr` values are an IANA-registered vocabulary (RFC 8176), while `acr` values are defined per identity provider, and the platform assumes no provider-specific vocabulary. +- An operation that requires assurance the presented token does not carry answers `403` with problem `type` `https://testing.nc3.lu/problems/step-up-required`. The type is the discriminator between step-up — remedied by a fresh authentication with an MFA method — and a plain forbidden, which no retry remedies. - 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 @@ -350,12 +351,14 @@ POST /api/v1/api-keys/{key_id}/revoke ```http GET /api/v1/statements +GET /api/v1/statement-responses 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). +- `GET /statement-responses` returns the caller's receipts, account-level and context-bound alike, each restating the `statement_key` and the exact `version` answered. A statement in force whose current version has no receipt is one the caller has yet to answer — the readback behind the acceptance prompt. Authenticated. - No v4.0 executable test is classified as intrusive, so no v4.0 launch requires a per-launch declaration. ## 15. Audit log diff --git a/src/nc3_testing_platform/core/errors.py b/src/nc3_testing_platform/core/errors.py index 0552717..3231412 100644 --- a/src/nc3_testing_platform/core/errors.py +++ b/src/nc3_testing_platform/core/errors.py @@ -72,6 +72,43 @@ def problem_responses(*status_codes: int) -> dict[int | str, dict]: } +STEP_UP_PROBLEM_TYPE = "https://testing.nc3.lu/problems/step-up-required" + + +def step_up_forbidden() -> dict[int | str, dict]: + """The `403` entry for an operation gated on current MFA assurance. + + The problem `type` is the discriminator a client retry loop needs: step-up + is remedied by a fresh authentication with an MFA method, where a plain + forbidden is not remedied by any retry. Merge into a route's `responses` + alongside :func:`problem_responses`. + """ + return { + 403: { + "model": ProblemDetail, + "description": ( + "Forbidden. When the operation requires current MFA assurance " + "that the presented token does not carry, `type` is " + f"`{STEP_UP_PROBLEM_TYPE}` and the remedy is a fresh " + "authentication with an MFA method." + ), + "content": { + PROBLEM_MEDIA_TYPE: { + "example": { + "type": STEP_UP_PROBLEM_TYPE, + "title": "Step-up authentication required", + "status": 403, + "detail": ( + "This operation requires a recent multi-factor " + "authentication event." + ), + } + } + }, + } + } + + async def _http_exception_handler( request: Request, exc: StarletteHTTPException ) -> ProblemResponse: @@ -165,14 +202,19 @@ def _responses(schema: dict) -> Iterator[dict]: def _relabel_problem_media_type(schema: dict) -> None: - """In-place: move ProblemDetail error bodies from application/json to problem+json.""" + """In-place: move ProblemDetail error bodies from application/json to problem+json. + + Keys already declared under problem+json (an `example`, as in + :func:`step_up_forbidden`) win over the generated body. + """ 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") + declared = content.get(PROBLEM_MEDIA_TYPE) or {} + content[PROBLEM_MEDIA_TYPE] = {**content.pop("application/json"), **declared} def _replace_default_validation_body(schema: dict) -> None: diff --git a/src/nc3_testing_platform/domains/api_keys/router.py b/src/nc3_testing_platform/domains/api_keys/router.py index 94c99e2..9e62b78 100644 --- a/src/nc3_testing_platform/domains/api_keys/router.py +++ b/src/nc3_testing_platform/domains/api_keys/router.py @@ -10,7 +10,7 @@ from fastapi import APIRouter, Response, status from nc3_testing_platform.core.enums import ApiKeyScope -from nc3_testing_platform.core.errors import problem_responses +from nc3_testing_platform.core.errors import problem_responses, step_up_forbidden from nc3_testing_platform.core.pagination import CursorPage, Page from nc3_testing_platform.core.schemas import ResourceId from nc3_testing_platform.core.security import ( @@ -72,7 +72,8 @@ async def list_api_keys(page: CursorPage) -> Page[ApiKey]: summary="Create an API key", responses={ 201: {"headers": NO_STORE_HEADERS}, - **problem_responses(401, 403, 422), + **problem_responses(401, 422), + **step_up_forbidden(), }, dependencies=[OidcRequired], ) @@ -92,7 +93,7 @@ async def create_api_key(body: ApiKeyCreate, response: Response) -> ApiKeyCreate @router.post( "/{key_id}/revoke", summary="Revoke an API key", - responses=problem_responses(401, 403, 404, 409), + responses={**problem_responses(401, 404, 409), **step_up_forbidden()}, dependencies=[OidcRequired], ) async def revoke_api_key(key_id: ResourceId, body: ApiKeyRevoke) -> ApiKey: diff --git a/src/nc3_testing_platform/domains/assets/router.py b/src/nc3_testing_platform/domains/assets/router.py index f944ba1..54000b5 100644 --- a/src/nc3_testing_platform/domains/assets/router.py +++ b/src/nc3_testing_platform/domains/assets/router.py @@ -8,7 +8,7 @@ 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.errors import problem_responses, step_up_forbidden from nc3_testing_platform.core.pagination import CursorPage, Page from nc3_testing_platform.core.schemas import ResourceId from nc3_testing_platform.core.security import ( @@ -133,7 +133,7 @@ async def get_verification(asset_id: ResourceId) -> DomainVerification: "/{asset_id}/verification", status_code=status.HTTP_201_CREATED, summary="Start a verification challenge", - responses=problem_responses(401, 403, 404, 409, 422), + responses={**problem_responses(401, 404, 409, 422), **step_up_forbidden()}, dependencies=[OidcRequired], ) async def create_verification( diff --git a/src/nc3_testing_platform/domains/statements/router.py b/src/nc3_testing_platform/domains/statements/router.py index 890333a..69045a8 100644 --- a/src/nc3_testing_platform/domains/statements/router.py +++ b/src/nc3_testing_platform/domains/statements/router.py @@ -21,6 +21,17 @@ _RECEIPT_ID = UUID("019ee1a2-1122-7d33-9e44-5f66a7b88c99") +def _sample_receipt() -> StatementResponseReceipt: + """The caller's acceptance of the current Terms version.""" + return StatementResponseReceipt( + id=_RECEIPT_ID, + statement_id=_STATEMENT_ID, + statement_key="terms_and_conditions", + version="2026-01-15", + responded_at=datetime(2026, 7, 31, 9, 0, tzinfo=UTC), + ) + + @router.get( "/statements", summary="List active statements", @@ -71,8 +82,19 @@ async def record_statement_response( 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), - ) + return _sample_receipt() + + +@router.get( + "/statement-responses", + summary="List the caller's statement responses", + responses=problem_responses(401), + dependencies=[CredentialRequired], +) +async def list_statement_responses() -> list[StatementResponseReceipt]: + """Receipts the caller has recorded, account-level and context-bound alike. + + The readback behind the acceptance prompt: a statement in force whose current + version has no receipt here is one the caller has yet to answer. + """ + return [_sample_receipt()] diff --git a/src/nc3_testing_platform/domains/statements/schemas.py b/src/nc3_testing_platform/domains/statements/schemas.py index 34cc84a..e97d8bd 100644 --- a/src/nc3_testing_platform/domains/statements/schemas.py +++ b/src/nc3_testing_platform/domains/statements/schemas.py @@ -73,6 +73,13 @@ class StatementResponseReceipt(BaseSchema): id: ResourceId statement_id: ResourceId + statement_key: str = Field( + description=( + "Statement identifier, restated so a receipt readback needs no join " + "against retired statement versions." + ), + ) + version: str = Field(description="Exact version answered.") responded_at: Timestamp context_type: str | None = Field( default=None, description="Null for an account-level response." diff --git a/tests/test_smoke_surface.py b/tests/test_smoke_surface.py index 83c3bd1..b9d05a6 100644 --- a/tests/test_smoke_surface.py +++ b/tests/test_smoke_surface.py @@ -217,6 +217,7 @@ class Case: "/api/v1/statement-responses", json_body={"statement_key": "terms_and_conditions", "version": "1.0.0"}, ), + Case("get", "/api/v1/statement-responses"), Case("get", "/api/v1/admin/audit-events"), Case("get", "/healthz"), Case("get", "/readyz"), From 56c686bb9edb61beb9ab3d36e37fde6f1c6f3bdc Mon Sep 17 00:00:00 2001 From: t0kubetsu Date: Mon, 10 Aug 2026 15:27:58 +0200 Subject: [PATCH 2/4] fix(review): align the assurance seam with the step-up type; POST receipt echoes the submission CodeRabbit round 1: - require_current_mfa_assurance() docstring promised 401 + RFC 9470 challenge, contradicting the 403s the three gated operations already declare and the minted step-up problem type; the seam now states the decided contract and why RFC 9470 (acr-based) is not used. - POST /statement-responses builds its receipt from the submitted statement_key and version instead of a fixed sample, so the mock echoes what was answered. Stateless-mock behavior otherwise unchanged: credential verification is an unwired seam, so caller-scoped storage belongs to the implementation phase. --- src/nc3_testing_platform/core/security.py | 4 +++- src/nc3_testing_platform/domains/statements/router.py | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/nc3_testing_platform/core/security.py b/src/nc3_testing_platform/core/security.py index 9cf121f..0fd49d8 100644 --- a/src/nc3_testing_platform/core/security.py +++ b/src/nc3_testing_platform/core/security.py @@ -104,7 +104,9 @@ def require_current_mfa_assurance(claims: dict[str, object]) -> None: """Rejects claims whose authentication event lacks current MFA assurance. Assurance is `amr` carrying an MFA method within the configured `max_age`. - A failure answers `401` with the RFC 9470 `insufficient_user_authentication` challenge. + A failure answers `403` with problem `type` :data:`~nc3_testing_platform.core.errors.STEP_UP_PROBLEM_TYPE`: + the caller is authenticated but under-assured, and the type distinguishes the remedy — a fresh authentication with an MFA method — from a plain forbidden, which no retry fixes. + RFC 9470's `WWW-Authenticate` challenge is deliberately not used: its parameters are `acr`-based, and §5.1 reads `amr` because `acr` vocabularies are provider-defined. """ raise NotImplementedError diff --git a/src/nc3_testing_platform/domains/statements/router.py b/src/nc3_testing_platform/domains/statements/router.py index 69045a8..d1d2141 100644 --- a/src/nc3_testing_platform/domains/statements/router.py +++ b/src/nc3_testing_platform/domains/statements/router.py @@ -82,7 +82,13 @@ async def record_statement_response( to the launch it belongs to and travels in the launch payload, so recording one here would produce a receipt attached to nothing. """ - return _sample_receipt() + return StatementResponseReceipt( + id=_RECEIPT_ID, + statement_id=_STATEMENT_ID, + statement_key=body.statement_key, + version=body.version, + responded_at=datetime(2026, 7, 31, 9, 0, tzinfo=UTC), + ) @router.get( From bb83a3233f215cf9e4fcb9b250cd0381e8c85fab Mon Sep 17 00:00:00 2001 From: t0kubetsu Date: Mon, 10 Aug 2026 19:28:56 +0200 Subject: [PATCH 3/4] fix(statements): reject context-bound statements on account-level recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler now does what its docstring and the API design already promised: a statement whose key carries a required context answers 409, because a per-launch declaration travels in the launch payload. The assurance seam's docstring also disambiguates its section reference — §5.1 is the API design's, reading RFC 8176 amr values, not a section of RFC 9470. --- src/nc3_testing_platform/core/security.py | 2 +- .../domains/statements/router.py | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/nc3_testing_platform/core/security.py b/src/nc3_testing_platform/core/security.py index 0fd49d8..b2b6156 100644 --- a/src/nc3_testing_platform/core/security.py +++ b/src/nc3_testing_platform/core/security.py @@ -106,7 +106,7 @@ def require_current_mfa_assurance(claims: dict[str, object]) -> None: Assurance is `amr` carrying an MFA method within the configured `max_age`. A failure answers `403` with problem `type` :data:`~nc3_testing_platform.core.errors.STEP_UP_PROBLEM_TYPE`: the caller is authenticated but under-assured, and the type distinguishes the remedy — a fresh authentication with an MFA method — from a plain forbidden, which no retry fixes. - RFC 9470's `WWW-Authenticate` challenge is deliberately not used: its parameters are `acr`-based, and §5.1 reads `amr` because `acr` vocabularies are provider-defined. + RFC 9470's `WWW-Authenticate` challenge is deliberately not used: its parameters are `acr`-based, while the API design's §5.1 reads the `amr` claim (RFC 8176's registered vocabulary) precisely because `acr` values are provider-defined. """ raise NotImplementedError diff --git a/src/nc3_testing_platform/domains/statements/router.py b/src/nc3_testing_platform/domains/statements/router.py index d1d2141..7e6687e 100644 --- a/src/nc3_testing_platform/domains/statements/router.py +++ b/src/nc3_testing_platform/domains/statements/router.py @@ -3,7 +3,7 @@ from datetime import UTC, datetime from uuid import UUID -from fastapi import APIRouter, status +from fastapi import APIRouter, HTTPException, status from nc3_testing_platform.core.enums import StatementResponseKind from nc3_testing_platform.core.errors import problem_responses @@ -20,6 +20,10 @@ _STATEMENT_ID = UUID("019ee1a2-0011-7c22-8d33-4e55f6a77b88") _RECEIPT_ID = UUID("019ee1a2-1122-7d33-9e44-5f66a7b88c99") +# The v4.0 statement keys whose statement carries a non-null +# required_context_type — code-owned, like the catalog itself (data model §5.1). +_CONTEXT_BOUND_KEYS = frozenset({"scan_target_permission", "intrusive_scan_risk_liability"}) + def _sample_receipt() -> StatementResponseReceipt: """The caller's acceptance of the current Terms version.""" @@ -82,6 +86,14 @@ async def record_statement_response( to the launch it belongs to and travels in the launch payload, so recording one here would produce a receipt attached to nothing. """ + if body.statement_key in _CONTEXT_BOUND_KEYS: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + "This statement requires a scan_job context; a per-launch " + "declaration travels in the launch payload." + ), + ) return StatementResponseReceipt( id=_RECEIPT_ID, statement_id=_STATEMENT_ID, From 6a96b008966de159dd52f181d870ada854404220 Mon Sep 17 00:00:00 2001 From: t0kubetsu Date: Mon, 10 Aug 2026 19:35:23 +0200 Subject: [PATCH 4/4] feat(statements): paginate the receipt readback from the start Account-level receipts stay countable on one hand, but context-bound receipts arrive per launch from v4.1, and turning a bare array into the Page envelope later would be a breaking contract change. Same CursorPage dependency and Page shape as every other collection. --- api/openapi.json | 152 +++++++++++++----- docs/reference/api-design-v4_0_1.md | 2 +- .../domains/statements/router.py | 11 +- 3 files changed, 120 insertions(+), 45 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 5a1c421..65198de 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -4407,39 +4407,13 @@ } }, "/api/v1/statement-responses": { - "get": { + "post": { "tags": [ "statements" ], - "summary": "List the caller's statement responses", - "description": "Receipts the caller has recorded, account-level and context-bound alike.\n\nThe readback behind the acceptance prompt: a statement in force whose current\nversion has no receipt here is one the caller has yet to answer.", - "operationId": "list_statement_responses_api_v1_statement_responses_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/StatementResponseReceipt" - }, - "type": "array", - "title": "Response List Statement Responses Api V1 Statement Responses Get" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetail" - } - } - } - } - }, + "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", "security": [ { "OpenIdConnect": [] @@ -4447,24 +4421,16 @@ { "ApiKey": [] } - ] - }, - "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": { + "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StatementResponseSubmission" } } - }, - "required": true + } }, "responses": { "201": { @@ -4517,7 +4483,15 @@ } } } - }, + } + }, + "get": { + "tags": [ + "statements" + ], + "summary": "List the caller's statement responses", + "description": "Receipts the caller has recorded, account-level and context-bound alike.\n\nThe readback behind the acceptance prompt: a statement in force whose current\nversion has no receipt here is one the caller has yet to answer.\n\nCursor-paginated from the start: account-level receipts stay countable on\none hand, but context-bound receipts arrive per launch, and turning a bare\narray into a page envelope later would break the contract.", + "operationId": "list_statement_responses_api_v1_statement_responses_get", "security": [ { "OpenIdConnect": [] @@ -4525,7 +4499,73 @@ { "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_StatementResponseReceipt_" + } + } + } + }, + "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/admin/audit-events": { @@ -6451,6 +6491,34 @@ ], "title": "Page[Schedule]" }, + "Page_StatementResponseReceipt_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/StatementResponseReceipt" + }, + "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[StatementResponseReceipt]" + }, "ProblemDetail": { "properties": { "type": { diff --git a/docs/reference/api-design-v4_0_1.md b/docs/reference/api-design-v4_0_1.md index 68632b1..6789385 100644 --- a/docs/reference/api-design-v4_0_1.md +++ b/docs/reference/api-design-v4_0_1.md @@ -358,7 +358,7 @@ 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). -- `GET /statement-responses` returns the caller's receipts, account-level and context-bound alike, each restating the `statement_key` and the exact `version` answered. A statement in force whose current version has no receipt is one the caller has yet to answer — the readback behind the acceptance prompt. Authenticated. +- `GET /statement-responses` returns the caller's receipts, account-level and context-bound alike, each restating the `statement_key` and the exact `version` answered. A statement in force whose current version has no receipt is one the caller has yet to answer — the readback behind the acceptance prompt. Authenticated; cursor-paginated, because context-bound receipts arrive per launch. - No v4.0 executable test is classified as intrusive, so no v4.0 launch requires a per-launch declaration. ## 15. Audit log diff --git a/src/nc3_testing_platform/domains/statements/router.py b/src/nc3_testing_platform/domains/statements/router.py index 7e6687e..8aad211 100644 --- a/src/nc3_testing_platform/domains/statements/router.py +++ b/src/nc3_testing_platform/domains/statements/router.py @@ -7,6 +7,7 @@ from nc3_testing_platform.core.enums import StatementResponseKind 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 CredentialRequired from nc3_testing_platform.domains.statements.schemas import ( Statement, @@ -109,10 +110,16 @@ async def record_statement_response( responses=problem_responses(401), dependencies=[CredentialRequired], ) -async def list_statement_responses() -> list[StatementResponseReceipt]: +async def list_statement_responses( + page: CursorPage, +) -> Page[StatementResponseReceipt]: """Receipts the caller has recorded, account-level and context-bound alike. The readback behind the acceptance prompt: a statement in force whose current version has no receipt here is one the caller has yet to answer. + + Cursor-paginated from the start: account-level receipts stay countable on + one hand, but context-bound receipts arrive per launch, and turning a bare + array into a page envelope later would break the contract. """ - return [_sample_receipt()] + return Page(items=[_sample_receipt()], next_cursor=None)