From d49d9195068717102fdfb9c7258a8cd690629e97 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 11:40:19 +0900 Subject: [PATCH 01/15] test(openapi): require repository license authority --- .../test_buyer_openapi_license_contract.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 scripts/test_buyer_openapi_license_contract.py diff --git a/scripts/test_buyer_openapi_license_contract.py b/scripts/test_buyer_openapi_license_contract.py new file mode 100644 index 000000000..cd1226fbd --- /dev/null +++ b/scripts/test_buyer_openapi_license_contract.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Keep the buyer OpenAPI license 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 the source license.""" + + def test_openapi_uses_repository_apache_license(self) -> None: + """Require the buyer connector seed to publish Apache-2.0 license metadata.""" + + 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.assertIn(" name: Apache-2.0", openapi) + self.assertIn("https://www.apache.org/licenses/LICENSE-2.0.html", openapi) + self.assertNotIn("Proprietary - buyer diligence use only", openapi) + + +if __name__ == "__main__": + unittest.main() From d5b2cdfcfe955efe6d26335eeefdc4e21952f87d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 11:43:47 +0900 Subject: [PATCH 02/15] fix(openapi): align buyer seed license with repository --- docs/deployment/clearfolio-buyer-connector.openapi.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/deployment/clearfolio-buyer-connector.openapi.yaml b/docs/deployment/clearfolio-buyer-connector.openapi.yaml index cb1e90f64..05568703d 100644 --- a/docs/deployment/clearfolio-buyer-connector.openapi.yaml +++ b/docs/deployment/clearfolio-buyer-connector.openapi.yaml @@ -7,8 +7,8 @@ info: 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. 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. @@ -670,4 +670,4 @@ components: type: string details: type: object - additionalProperties: true + additionalProperties: true \ No newline at end of file From ea00bfaf99d171c017a0eebaf8fb9cae8390a5be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 13:46:42 +0900 Subject: [PATCH 03/15] test(openapi): reject hard-coded buyer demo identities --- scripts/test_buyer_openapi_license_contract.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/test_buyer_openapi_license_contract.py b/scripts/test_buyer_openapi_license_contract.py index cd1226fbd..325b97c4f 100644 --- a/scripts/test_buyer_openapi_license_contract.py +++ b/scripts/test_buyer_openapi_license_contract.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Keep the buyer OpenAPI license metadata aligned with repository authority.""" +"""Keep buyer OpenAPI acquisition metadata aligned with repository authority.""" from __future__ import annotations @@ -13,7 +13,7 @@ class BuyerOpenApiLicenseContractTest(unittest.TestCase): - """Prevent acquisition-facing API metadata from contradicting the source license.""" + """Prevent acquisition-facing API metadata from contradicting product authority.""" def test_openapi_uses_repository_apache_license(self) -> None: """Require the buyer connector seed to publish Apache-2.0 license metadata.""" @@ -26,6 +26,16 @@ def test_openapi_uses_repository_apache_license(self) -> None: self.assertIn("https://www.apache.org/licenses/LICENSE-2.0.html", openapi) 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() From deb1953f9b1175d2858c9391f3b9aca75506daf8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:19:42 +0900 Subject: [PATCH 04/15] fix(openapi): remove demo identity from integration contract --- docs/deployment/clearfolio-buyer-connector.openapi.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/deployment/clearfolio-buyer-connector.openapi.yaml b/docs/deployment/clearfolio-buyer-connector.openapi.yaml index 05568703d..c36bd3d5c 100644 --- a/docs/deployment/clearfolio-buyer-connector.openapi.yaml +++ b/docs/deployment/clearfolio-buyer-connector.openapi.yaml @@ -5,7 +5,7 @@ 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: Apache-2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html @@ -321,14 +321,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 @@ -670,4 +670,4 @@ components: type: string details: type: object - additionalProperties: true \ No newline at end of file + additionalProperties: true From 243216fe612c0210202751f261984268de6b3c37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:23:11 +0900 Subject: [PATCH 05/15] test(openapi): parse license metadata structurally --- .../BuyerOpenApiLicenseContractTest.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/config/BuyerOpenApiLicenseContractTest.java 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")); + } +} From 82b7b38e1068a1e5700a9fa486a0d6b5b4a4be70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:23:34 +0900 Subject: [PATCH 06/15] test(openapi): leave structured license assertions to YAML parser --- scripts/test_buyer_openapi_license_contract.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/scripts/test_buyer_openapi_license_contract.py b/scripts/test_buyer_openapi_license_contract.py index 325b97c4f..e9f26e68f 100644 --- a/scripts/test_buyer_openapi_license_contract.py +++ b/scripts/test_buyer_openapi_license_contract.py @@ -15,15 +15,13 @@ class BuyerOpenApiLicenseContractTest(unittest.TestCase): """Prevent acquisition-facing API metadata from contradicting product authority.""" - def test_openapi_uses_repository_apache_license(self) -> None: - """Require the buyer connector seed to publish Apache-2.0 license metadata.""" + 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.assertIn(" name: Apache-2.0", openapi) - self.assertIn("https://www.apache.org/licenses/LICENSE-2.0.html", openapi) self.assertNotIn("Proprietary - buyer diligence use only", openapi) def test_openapi_examples_do_not_encode_buyer_demo_identity(self) -> None: From 7e97ee63ae26efb3d69200a85cb55f07352b5403 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 16:04:25 +0900 Subject: [PATCH 07/15] test(openapi): require shipped job deletion contract --- .../config/BuyerOpenApiRouteContractTest.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java 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..160e68cd0 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java @@ -0,0 +1,47 @@ +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.assertNotNull; +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 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 { + Object parsed; + try (Reader reader = Files.newBufferedReader(OPENAPI_PATH)) { + parsed = new Yaml().load(reader); + } + + Map root = assertInstanceOf(Map.class, parsed); + 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")); + assertNotNull(deleteOperation.get("parameters")); + + Map responses = assertInstanceOf(Map.class, deleteOperation.get("responses")); + assertTrue(responses.containsKey("204")); + assertTrue(responses.containsKey("401")); + assertTrue(responses.containsKey("403")); + assertTrue(responses.containsKey("404")); + } +} From 2b3dd030fd96092d63af4a42495749ce8d9df7c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 16:10:50 +0900 Subject: [PATCH 08/15] fix(openapi): document shipped job deletion route --- .../clearfolio-buyer-connector.openapi.yaml | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/deployment/clearfolio-buyer-connector.openapi.yaml b/docs/deployment/clearfolio-buyer-connector.openapi.yaml index c36bd3d5c..555faba57 100644 --- a/docs/deployment/clearfolio-buyer-connector.openapi.yaml +++ b/docs/deployment/clearfolio-buyer-connector.openapi.yaml @@ -19,7 +19,7 @@ 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 @@ -98,6 +98,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: @@ -340,7 +362,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 From 41e26e0051058c2ff1456c4e26ba751f70531e5f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 16:13:15 +0900 Subject: [PATCH 09/15] test(openapi): require signed artifact byte contract --- .../config/BuyerOpenApiRouteContractTest.java | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java b/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java index 160e68cd0..bcbae68a8 100644 --- a/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java +++ b/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java @@ -9,6 +9,7 @@ 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; @@ -24,12 +25,7 @@ class BuyerOpenApiRouteContractTest { @Test void openApiDescribesProtectedMainConversionJobDeleteRoute() throws IOException { - Object parsed; - try (Reader reader = Files.newBufferedReader(OPENAPI_PATH)) { - parsed = new Yaml().load(reader); - } - - Map root = assertInstanceOf(Map.class, parsed); + 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")); @@ -44,4 +40,40 @@ void openApiDescribesProtectedMainConversionJobDeleteRoute() throws IOException 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")); + assertNotNull(getOperation.get("parameters")); + assertInstanceOf(List.class, 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); + } + + 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")); + } + + 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); + } } From 92255dad4019d9c890a6167ff4eb02ca861aa405 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 16:18:08 +0900 Subject: [PATCH 10/15] fix(openapi): document signed artifact byte delivery --- .../clearfolio-buyer-connector.openapi.yaml | 62 ++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/docs/deployment/clearfolio-buyer-connector.openapi.yaml b/docs/deployment/clearfolio-buyer-connector.openapi.yaml index 555faba57..07424f70a 100644 --- a/docs/deployment/clearfolio-buyer-connector.openapi.yaml +++ b/docs/deployment/clearfolio-buyer-connector.openapi.yaml @@ -23,7 +23,7 @@ tags: - 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. paths: @@ -221,7 +221,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" @@ -276,6 +276,47 @@ 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. + 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. /api/v1/analytics/kpi-snapshot: get: tags: @@ -336,6 +377,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 @@ -424,6 +475,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. From 1e1b142e159e4ce4d4240ce052e71a7df9b9d8fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 16:19:52 +0900 Subject: [PATCH 11/15] test(openapi): require shipped liveness contract --- .../config/BuyerOpenApiRouteContractTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java b/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java index bcbae68a8..9942b4829 100644 --- a/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java +++ b/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java @@ -69,6 +69,24 @@ void openApiDescribesProtectedMainSignedArtifactByteRoute() throws IOException { 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 Map loadOpenApi() throws IOException { Object parsed; try (Reader reader = Files.newBufferedReader(OPENAPI_PATH)) { From 123dd237cf9a4e05478de532590a4eef27e90109 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 16:22:25 +0900 Subject: [PATCH 12/15] fix(openapi): document protected-main liveness --- .../clearfolio-buyer-connector.openapi.yaml | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/deployment/clearfolio-buyer-connector.openapi.yaml b/docs/deployment/clearfolio-buyer-connector.openapi.yaml index 07424f70a..4ce7ab176 100644 --- a/docs/deployment/clearfolio-buyer-connector.openapi.yaml +++ b/docs/deployment/clearfolio-buyer-connector.openapi.yaml @@ -26,6 +26,8 @@ tags: 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: @@ -367,6 +369,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: @@ -514,6 +534,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: From b6d79bce1cbbcc34e2743891788d96d40ab42d87 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 16:23:59 +0900 Subject: [PATCH 13/15] test(openapi): validate delete path parameter semantics --- .../config/BuyerOpenApiRouteContractTest.java | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java b/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java index 9942b4829..1017aa6b1 100644 --- a/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java +++ b/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java @@ -32,7 +32,7 @@ void openApiDescribesProtectedMainConversionJobDeleteRoute() throws IOException assertEquals("deleteConversionJob", deleteOperation.get("operationId")); assertTrue(String.valueOf(deleteOperation.get("description")).contains("job:delete")); - assertNotNull(deleteOperation.get("parameters")); + assertHasRequiredPathParameter(root, deleteOperation, "jobId"); Map responses = assertInstanceOf(Map.class, deleteOperation.get("responses")); assertTrue(responses.containsKey("204")); @@ -87,6 +87,38 @@ void openApiDescribesUnauthenticatedProtectedMainLivenessRoute() throws IOExcept assertEquals("#/components/schemas/HealthResponse", schema.get("$ref")); } + private static void assertHasRequiredPathParameter( + Map root, + Map operation, + String expectedName + ) { + List parameters = assertInstanceOf(List.class, operation.get("parameters")); + assertTrue( + parameters.stream() + .map(parameter -> resolveParameter(root, parameter)) + .anyMatch(parameter -> expectedName.equals(parameter.get("name")) + && "path".equals(parameter.get("in")) + && Boolean.TRUE.equals(parameter.get("required"))), + "missing required path parameter " + expectedName + ); + } + + 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)) { From 581f53172d7ba2f8b88086111f9661d8ca953e9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 22:02:39 +0900 Subject: [PATCH 14/15] test(openapi): require artifact range and auth contract values --- .../config/BuyerOpenApiRouteContractTest.java | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java b/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java index 1017aa6b1..50071256f 100644 --- a/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java +++ b/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java @@ -2,7 +2,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; @@ -32,7 +31,7 @@ void openApiDescribesProtectedMainConversionJobDeleteRoute() throws IOException assertEquals("deleteConversionJob", deleteOperation.get("operationId")); assertTrue(String.valueOf(deleteOperation.get("description")).contains("job:delete")); - assertHasRequiredPathParameter(root, deleteOperation, "jobId"); + assertHasParameter(root, deleteOperation, "jobId", "path", true); Map responses = assertInstanceOf(Map.class, deleteOperation.get("responses")); assertTrue(responses.containsKey("204")); @@ -49,13 +48,20 @@ void openApiDescribesProtectedMainSignedArtifactByteRoute() throws IOException { Map getOperation = assertInstanceOf(Map.class, artifactPath.get("get")); assertEquals("getPdfArtifact", getOperation.get("operationId")); - assertNotNull(getOperation.get("parameters")); - assertInstanceOf(List.class, getOperation.get("security")); + 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")); @@ -87,19 +93,37 @@ void openApiDescribesUnauthenticatedProtectedMainLivenessRoute() throws IOExcept assertEquals("#/components/schemas/HealthResponse", schema.get("$ref")); } - private static void assertHasRequiredPathParameter( + private static void assertHasParameter( Map root, Map operation, - String expectedName + 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")) - && "path".equals(parameter.get("in")) - && Boolean.TRUE.equals(parameter.get("required"))), - "missing required path parameter " + expectedName + && 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 ); } From 71c1e3a9cd26bbd4d193811d8d35927a9af60fe2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 22:10:39 +0900 Subject: [PATCH 15/15] fix(openapi): declare artifact Content-Range responses --- .../clearfolio-buyer-connector.openapi.yaml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/deployment/clearfolio-buyer-connector.openapi.yaml b/docs/deployment/clearfolio-buyer-connector.openapi.yaml index 4ce7ab176..6dc4dc201 100644 --- a/docs/deployment/clearfolio-buyer-connector.openapi.yaml +++ b/docs/deployment/clearfolio-buyer-connector.openapi.yaml @@ -306,6 +306,11 @@ paths: 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: @@ -319,6 +324,11 @@ paths: 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: @@ -780,4 +790,4 @@ components: type: string details: type: object - additionalProperties: true + additionalProperties: true \ No newline at end of file