diff --git a/changelog.d/maintenance/11363-openapi-try-operation-coverage.md b/changelog.d/maintenance/11363-openapi-try-operation-coverage.md new file mode 100644 index 0000000000..f666967041 --- /dev/null +++ b/changelog.d/maintenance/11363-openapi-try-operation-coverage.md @@ -0,0 +1 @@ +- **docs(openapi):** document the conditionally management-authenticated, same-origin `POST /api/openapi/try` proxy contract and restore the release branch's operation-coverage ratchet ([#11363](https://github.com/diegosouzapw/OmniRoute/pull/11363)) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 540f2abd52..9dae1d0150 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -6931,6 +6931,104 @@ paths: "500": description: Failed to parse OpenAPI spec + /api/openapi/try: + post: + tags: [System] + summary: Proxy an API Explorer request to an OmniRoute endpoint + description: >- + Executes an API Explorer request through a server-side, same-origin proxy. The target + must start with `/api/`, `/v1/`, `/v1beta/`, `/a2a`, or + `/.well-known/agent.json`; protocol-relative and cross-origin targets are rejected. + Hop-by-hop, proxy, host, cookie, and forwarding headers supplied in `headers` are + stripped, while any dashboard cookie on the original request is forwarded separately. + When `requireLogin` is disabled, the management-auth bypass mirrors the runtime setting; + otherwise a management Bearer credential or dashboard session is required. Failures + caught after authentication, including request JSON parsing, fetch, and response-body + parsing failures, are returned in the normal HTTP 200 result envelope so the Explorer + can display them; `status: 0` identifies that caught-failure path. + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [path] + properties: + method: + type: string + enum: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS] + default: GET + path: + type: string + minLength: 1 + pattern: "^/(?:api/|v1/|v1beta/|a2a|\\.well-known/agent\\.json)" + description: Same-origin OmniRoute API path, optionally including a query string. + headers: + type: object + default: {} + additionalProperties: + type: string + description: >- + Headers to forward after removing connection, content-length, cookie, host, + keep-alive, proxy-authenticate, proxy-authorization, te, trailer, + transfer-encoding, upgrade, x-forwarded-for, x-forwarded-host, and + x-forwarded-proto headers. + body: + description: >- + Optional JSON value. A truthy value is serialized unless it is already a + string, and is not forwarded when `method` is `GET`. + responses: + "200": + description: Upstream response or displayable caught-failure envelope + content: + application/json: + schema: + type: object + additionalProperties: false + required: [status, statusText, headers, body, latencyMs, contentType] + properties: + status: + type: integer + minimum: 0 + description: Upstream HTTP status, or 0 when request processing throws. + statusText: + type: string + headers: + type: object + additionalProperties: + type: string + body: + description: >- + Parsed JSON, response text truncated after 10,000 characters, or a sanitized + caught-error object. + latencyMs: + type: integer + minimum: 0 + contentType: + type: string + "400": + description: Invalid request body or non-same-origin path + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/ValidationErrorResponse" + - type: object + required: [error] + properties: + error: + type: string + example: Path must be same-origin + "401": + $ref: "#/components/responses/ManagementAuthenticationRequired" + "403": + $ref: "#/components/responses/ManagementInvalidToken" + "503": + $ref: "#/components/responses/InternalError" + # ─── Agent Skills Catalog ──────────────────────────────────────────────────── /api/agent-skills: diff --git a/tests/unit/openapi-security-tiers.test.ts b/tests/unit/openapi-security-tiers.test.ts index 9cff84f00c..e3b12804a6 100644 --- a/tests/unit/openapi-security-tiers.test.ts +++ b/tests/unit/openapi-security-tiers.test.ts @@ -49,6 +49,76 @@ test("GET /api/openapi/spec documents its conditional management auth contract", ); }); +test("POST /api/openapi/try documents its bounded management proxy contract", () => { + const operation = paths["/api/openapi/try"]?.post; + + assert.ok(operation, "POST /api/openapi/try must be present in docs/openapi.yaml"); + assert.deepEqual(operation.security, [{ BearerAuth: [] }, { ManagementSessionAuth: [] }]); + assert.match(operation.description ?? "", /same-origin/); + assert.match(operation.description ?? "", /When `requireLogin` is disabled/); + + const requestBody = operation.requestBody; + const requestSchema = requestBody?.content?.["application/json"]?.schema; + assert.equal(requestBody?.required, true); + assert.equal(requestSchema?.type, "object"); + assert.deepEqual(requestSchema?.required, ["path"]); + assert.deepEqual(requestSchema?.properties?.method?.enum, [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", + ]); + assert.equal(requestSchema?.properties?.method?.default, "GET"); + assert.equal(requestSchema?.properties?.path?.minLength, 1); + assert.equal( + requestSchema?.properties?.path?.pattern, + "^/(?:api/|v1/|v1beta/|a2a|\\.well-known/agent\\.json)" + ); + assert.equal(requestSchema?.properties?.headers?.type, "object"); + assert.deepEqual(requestSchema?.properties?.headers?.additionalProperties, { + type: "string", + }); + assert.deepEqual(requestSchema?.properties?.headers?.default, {}); + assert.ok("body" in requestSchema.properties); + + const successSchema = operation.responses?.["200"]?.content?.["application/json"]?.schema; + assert.equal(successSchema?.type, "object"); + assert.equal(successSchema?.additionalProperties, false); + assert.deepEqual(successSchema?.required, [ + "status", + "statusText", + "headers", + "body", + "latencyMs", + "contentType", + ]); + assert.equal(successSchema?.properties?.status?.type, "integer"); + assert.equal(successSchema?.properties?.status?.minimum, 0); + assert.equal(successSchema?.properties?.statusText?.type, "string"); + assert.equal(successSchema?.properties?.headers?.type, "object"); + assert.deepEqual(successSchema?.properties?.headers?.additionalProperties, { + type: "string", + }); + assert.match(successSchema?.properties?.body?.description ?? "", /10,000 characters/); + assert.equal(successSchema?.properties?.latencyMs?.type, "integer"); + assert.equal(successSchema?.properties?.latencyMs?.minimum, 0); + assert.equal(successSchema?.properties?.contentType?.type, "string"); + + const badRequestSchema = operation.responses?.["400"]?.content?.["application/json"]?.schema; + assert.equal(badRequestSchema?.oneOf?.length, 2); + assert.equal(badRequestSchema?.oneOf?.[0]?.$ref, "#/components/schemas/ValidationErrorResponse"); + assert.equal(badRequestSchema?.oneOf?.[1]?.properties?.error?.type, "string"); + assert.equal( + operation.responses?.["401"]?.$ref, + "#/components/responses/ManagementAuthenticationRequired" + ); + assert.equal(operation.responses?.["403"]?.$ref, "#/components/responses/ManagementInvalidToken"); + assert.equal(operation.responses?.["503"]?.$ref, "#/components/responses/InternalError"); +}); + test("every x-always-protected path matches ALWAYS_PROTECTED_API_PATHS in routeGuard.ts", () => { for (const [pathStr, methods] of Object.entries(paths)) { if (!methods || typeof methods !== "object") continue;