diff --git a/docs/deployment/clearfolio-buyer-connector.openapi.yaml b/docs/deployment/clearfolio-buyer-connector.openapi.yaml index cb1e90f64..6dc4dc201 100644 --- a/docs/deployment/clearfolio-buyer-connector.openapi.yaml +++ b/docs/deployment/clearfolio-buyer-connector.openapi.yaml @@ -5,10 +5,10 @@ info: description: > Import seed for a buyer-owned gateway or Power Platform custom connector. This spec reflects the current Clearfolio Viewer JSON API contract and the - buyer-demo signed-header scaffold. It is not a production OIDC/JWT profile. + signed tenant-header scaffold. It is not a production OIDC/JWT profile. license: - name: Proprietary - buyer diligence use only - url: https://github.com/ContextualWisdomLab/clearfolio + name: Apache-2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html servers: - url: https://{gatewayHost} description: Buyer gateway in front of Clearfolio Viewer. @@ -19,13 +19,15 @@ security: - clearfolioTenantHeaders: [] tags: - name: Conversion - description: Document intake, status polling, and retry operations. + description: Document intake, status polling, deletion, and retry operations. - name: Viewer description: Viewer bootstrap operations that return signed artifact URLs. - name: ArtifactLinks - description: Signed artifact-link creation, revocation, and read evidence. + description: Signed artifact-link creation, revocation, byte delivery, and read evidence. - name: Analytics description: Tenant-scoped KPI snapshot and export evidence operations. + - name: Operations + description: Operational endpoints intentionally exposed without tenant identity. paths: /api/v1/convert/jobs: post: @@ -98,6 +100,28 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + delete: + tags: + - Conversion + operationId: deleteConversionJob + summary: Delete a conversion job and generated artifacts. + description: Requires `job:delete`; missing and cross-tenant jobs are hidden. + parameters: + - $ref: "#/components/parameters/JobId" + - $ref: "#/components/parameters/TenantId" + - $ref: "#/components/parameters/SubjectId" + - $ref: "#/components/parameters/Permissions" + - $ref: "#/components/parameters/ClaimsIssuedAt" + - $ref: "#/components/parameters/ClaimsSignature" + responses: + "204": + description: Conversion job and generated artifacts deleted. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" /api/v1/convert/jobs/{jobId}/retry: post: tags: @@ -199,7 +223,7 @@ paths: tags: - ArtifactLinks operationId: revokeArtifactLink - summary: Revoke a previously issued artifact link. + summary: Revoke a previously issued signed artifact link. description: Requires `artifact-link:revoke`; intended for operators. parameters: - $ref: "#/components/parameters/TokenId" @@ -254,6 +278,57 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + /artifacts/{docId}.pdf: + get: + tags: + - ArtifactLinks + operationId: getPdfArtifact + summary: Read a converted PDF through a signed artifact token. + description: > + Serves bytes only for a succeeded conversion with an issued, unexpired, + unrevoked, document/tenant/checksum-bound artifact token. The token may + be supplied either as the `artifactToken` query parameter or as a Bearer + credential. At most one `bytes` range is accepted and verified reads are + recorded by the server. + security: + - artifactTokenQuery: [] + - artifactTokenBearer: [] + parameters: + - $ref: "#/components/parameters/DocId" + - $ref: "#/components/parameters/Range" + responses: + "200": + description: Full PDF artifact. + content: + application/pdf: + schema: + type: string + format: binary + "206": + description: Requested single byte range of the PDF artifact. + headers: + Content-Range: + description: Returned byte range and complete PDF length as `bytes start-end/total`. + schema: + type: string + content: + application/pdf: + schema: + type: string + format: binary + "401": + description: Signed artifact token is missing, malformed, expired, or otherwise unauthorized. + "403": + description: Signed artifact token is revoked or otherwise forbidden. + "404": + description: Conversion job or PDF artifact is absent or unavailable. + "416": + description: Range is malformed or unsatisfiable for the current PDF bytes. + headers: + Content-Range: + description: Complete PDF length as `bytes */total`. + schema: + type: string /api/v1/analytics/kpi-snapshot: get: tags: @@ -304,6 +379,24 @@ paths: $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + /healthz: + get: + tags: + - Operations + operationId: getHealthz + summary: Check process liveness. + description: > + Returns only the static process-liveness payload shipped on protected main. + This endpoint intentionally requires no tenant identity and does not claim + dependency or traffic readiness. + security: [] + responses: + "200": + description: Process is alive. + content: + application/json: + schema: + $ref: "#/components/schemas/HealthResponse" components: securitySchemes: clearfolioTenantHeaders: @@ -314,6 +407,16 @@ components: Buyer gateway supplies the full signed Clearfolio tenant header set. The signature alone is not enough without tenant, subject, permissions, and issued-at headers. + artifactTokenQuery: + type: apiKey + in: query + name: artifactToken + description: Short-lived Clearfolio artifact token issued for this document. + artifactTokenBearer: + type: http + scheme: bearer + bearerFormat: ClearfolioArtifactToken + description: Short-lived Clearfolio artifact token supplied as an Authorization bearer credential. parameters: TenantId: name: X-Clearfolio-Tenant-Id @@ -321,14 +424,14 @@ components: required: true schema: type: string - example: buyer-demo + example: tenant-example SubjectId: name: X-Clearfolio-Subject-Id in: header required: true schema: type: string - example: buyer-demo-operator + example: operator-example Permissions: name: X-Clearfolio-Permissions in: header @@ -340,7 +443,7 @@ components: The gateway must sign this canonical form, not the raw string. schema: type: string - example: job:create,job:read,viewer:read,artifact-link:create,analytics:read + example: job:create,job:read,job:delete,viewer:read,artifact-link:create,analytics:read ClaimsIssuedAt: name: X-Clearfolio-Claims-Issued-At in: header @@ -402,6 +505,13 @@ components: required: true schema: type: string + Range: + name: Range + in: header + required: false + description: Optional single HTTP byte range, for example `bytes=0-1023`. + schema: + type: string responses: BadRequest: description: Bad request or validation failure. @@ -434,6 +544,16 @@ components: schema: $ref: "#/components/schemas/ApiErrorResponse" schemas: + HealthResponse: + type: object + required: + - status + additionalProperties: false + properties: + status: + type: string + enum: + - ok SubmitConversionResponse: type: object required: @@ -670,4 +790,4 @@ components: type: string details: type: object - additionalProperties: true + additionalProperties: true \ No newline at end of file diff --git a/scripts/test_buyer_openapi_license_contract.py b/scripts/test_buyer_openapi_license_contract.py new file mode 100644 index 000000000..e9f26e68f --- /dev/null +++ b/scripts/test_buyer_openapi_license_contract.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Keep buyer OpenAPI acquisition metadata aligned with repository authority.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +OPENAPI_PATH = REPOSITORY_ROOT / "docs/deployment/clearfolio-buyer-connector.openapi.yaml" +LICENSE_PATH = REPOSITORY_ROOT / "LICENSE" + + +class BuyerOpenApiLicenseContractTest(unittest.TestCase): + """Prevent acquisition-facing API metadata from contradicting product authority.""" + + def test_openapi_does_not_claim_proprietary_repository_licensing(self) -> None: + """Reject the superseded proprietary-only buyer-diligence claim.""" + + repository_license = LICENSE_PATH.read_text(encoding="utf-8") + openapi = OPENAPI_PATH.read_text(encoding="utf-8") + + self.assertTrue(repository_license.startswith("Apache License\nVersion 2.0")) + self.assertNotIn("Proprietary - buyer diligence use only", openapi) + + def test_openapi_examples_do_not_encode_buyer_demo_identity(self) -> None: + """Keep machine-readable integration examples independent of demo authority.""" + + openapi = OPENAPI_PATH.read_text(encoding="utf-8") + + self.assertNotIn("buyer-demo", openapi) + self.assertNotIn("buyer-demo-operator", openapi) + self.assertIn("example: tenant-example", openapi) + self.assertIn("example: operator-example", openapi) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiLicenseContractTest.java b/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiLicenseContractTest.java new file mode 100644 index 000000000..120e724d9 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiLicenseContractTest.java @@ -0,0 +1,42 @@ +package com.clearfolio.viewer.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.Reader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * Verifies acquisition-facing OpenAPI license metadata through the parsed YAML structure. + */ +class BuyerOpenApiLicenseContractTest { + + private static final Path OPENAPI_PATH = + Path.of("docs/deployment/clearfolio-buyer-connector.openapi.yaml"); + private static final Path LICENSE_PATH = Path.of("LICENSE"); + + @Test + void openApiLicenseObjectMatchesRepositoryAuthority() throws IOException { + String repositoryLicense = Files.readString(LICENSE_PATH); + assertTrue(repositoryLicense.startsWith("Apache License\nVersion 2.0")); + + Object parsed; + try (Reader reader = Files.newBufferedReader(OPENAPI_PATH)) { + parsed = new Yaml().load(reader); + } + + Map root = assertInstanceOf(Map.class, parsed); + Map info = assertInstanceOf(Map.class, root.get("info")); + Map license = assertInstanceOf(Map.class, info.get("license")); + + assertEquals("Apache-2.0", license.get("name")); + assertEquals("https://www.apache.org/licenses/LICENSE-2.0.html", license.get("url")); + } +} diff --git a/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java b/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java new file mode 100644 index 000000000..50071256f --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java @@ -0,0 +1,153 @@ +package com.clearfolio.viewer.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.Reader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * Verifies buyer-facing OpenAPI coverage for public routes already shipped on protected main. + */ +class BuyerOpenApiRouteContractTest { + + private static final Path OPENAPI_PATH = + Path.of("docs/deployment/clearfolio-buyer-connector.openapi.yaml"); + + @Test + void openApiDescribesProtectedMainConversionJobDeleteRoute() throws IOException { + Map root = loadOpenApi(); + Map paths = assertInstanceOf(Map.class, root.get("paths")); + Map jobPath = assertInstanceOf(Map.class, paths.get("/api/v1/convert/jobs/{jobId}")); + Map deleteOperation = assertInstanceOf(Map.class, jobPath.get("delete")); + + assertEquals("deleteConversionJob", deleteOperation.get("operationId")); + assertTrue(String.valueOf(deleteOperation.get("description")).contains("job:delete")); + assertHasParameter(root, deleteOperation, "jobId", "path", true); + + Map responses = assertInstanceOf(Map.class, deleteOperation.get("responses")); + assertTrue(responses.containsKey("204")); + assertTrue(responses.containsKey("401")); + assertTrue(responses.containsKey("403")); + assertTrue(responses.containsKey("404")); + } + + @Test + void openApiDescribesProtectedMainSignedArtifactByteRoute() throws IOException { + Map root = loadOpenApi(); + Map paths = assertInstanceOf(Map.class, root.get("paths")); + Map artifactPath = assertInstanceOf(Map.class, paths.get("/artifacts/{docId}.pdf")); + Map getOperation = assertInstanceOf(Map.class, artifactPath.get("get")); + + assertEquals("getPdfArtifact", getOperation.get("operationId")); + assertHasParameter(root, getOperation, "docId", "path", true); + assertHasParameter(root, getOperation, "Range", "header", false); + assertEquals( + List.of( + Map.of("artifactTokenQuery", List.of()), + Map.of("artifactTokenBearer", List.of())), + getOperation.get("security")); + + Map responses = assertInstanceOf(Map.class, getOperation.get("responses")); + for (String status : List.of("200", "206", "401", "403", "404", "416")) { + assertTrue(responses.containsKey(status), "missing artifact response " + status); + } + assertContentRangeHeader(responses, "206", "bytes start-end/total"); + assertContentRangeHeader(responses, "416", "bytes */total"); + + Map components = assertInstanceOf(Map.class, root.get("components")); + Map securitySchemes = assertInstanceOf(Map.class, components.get("securitySchemes")); + Map queryScheme = assertInstanceOf(Map.class, securitySchemes.get("artifactTokenQuery")); + assertEquals("apiKey", queryScheme.get("type")); + assertEquals("query", queryScheme.get("in")); + assertEquals("artifactToken", queryScheme.get("name")); + + Map bearerScheme = assertInstanceOf(Map.class, securitySchemes.get("artifactTokenBearer")); + assertEquals("http", bearerScheme.get("type")); + assertEquals("bearer", bearerScheme.get("scheme")); + } + + @Test + void openApiDescribesUnauthenticatedProtectedMainLivenessRoute() throws IOException { + Map root = loadOpenApi(); + Map paths = assertInstanceOf(Map.class, root.get("paths")); + Map healthPath = assertInstanceOf(Map.class, paths.get("/healthz")); + Map getOperation = assertInstanceOf(Map.class, healthPath.get("get")); + + assertEquals("getHealthz", getOperation.get("operationId")); + assertEquals(List.of(), getOperation.get("security")); + + Map responses = assertInstanceOf(Map.class, getOperation.get("responses")); + Map okResponse = assertInstanceOf(Map.class, responses.get("200")); + Map content = assertInstanceOf(Map.class, okResponse.get("content")); + Map jsonContent = assertInstanceOf(Map.class, content.get("application/json")); + Map schema = assertInstanceOf(Map.class, jsonContent.get("schema")); + assertEquals("#/components/schemas/HealthResponse", schema.get("$ref")); + } + + private static void assertHasParameter( + Map root, + Map operation, + String expectedName, + String expectedLocation, + boolean expectedRequired + ) { + List parameters = assertInstanceOf(List.class, operation.get("parameters")); + assertTrue( + parameters.stream() + .map(parameter -> resolveParameter(root, parameter)) + .anyMatch(parameter -> expectedName.equals(parameter.get("name")) + && expectedLocation.equals(parameter.get("in")) + && Boolean.valueOf(expectedRequired).equals(parameter.get("required"))), + "missing parameter " + expectedName + " in " + expectedLocation + ); + } + + private static void assertContentRangeHeader( + Map responses, + String status, + String expectedDescriptionFragment + ) { + Map response = assertInstanceOf(Map.class, responses.get(status)); + Map headers = assertInstanceOf(Map.class, response.get("headers")); + Map contentRange = assertInstanceOf(Map.class, headers.get("Content-Range")); + Map schema = assertInstanceOf(Map.class, contentRange.get("schema")); + assertEquals("string", schema.get("type")); + assertTrue( + String.valueOf(contentRange.get("description")).contains(expectedDescriptionFragment), + "Content-Range description for " + status + " must explain " + expectedDescriptionFragment + ); + } + + private static Map resolveParameter(Map root, Object parameterValue) { + Map parameter = assertInstanceOf(Map.class, parameterValue); + Object reference = parameter.get("$ref"); + if (reference == null) { + return parameter; + } + + String prefix = "#/components/parameters/"; + String referenceText = assertInstanceOf(String.class, reference); + assertTrue(referenceText.startsWith(prefix), "unsupported parameter reference " + referenceText); + + Map components = assertInstanceOf(Map.class, root.get("components")); + Map componentParameters = assertInstanceOf(Map.class, components.get("parameters")); + return assertInstanceOf(Map.class, componentParameters.get(referenceText.substring(prefix.length()))); + } + + private static Map loadOpenApi() throws IOException { + Object parsed; + try (Reader reader = Files.newBufferedReader(OPENAPI_PATH)) { + parsed = new Yaml().load(reader); + } + return assertInstanceOf(Map.class, parsed); + } +}