diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0b97de2..b3ebba0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ jobs: - run: npm ci continue-on-error: true + - name: Check OpenAPI artifact and contract drift + run: npm run openapi:check + - run: npm run lint || true continue-on-error: true diff --git a/openapi.yaml b/openapi.yaml index 6df1e4c7..7d7fe6b9 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1138,6 +1138,30 @@ components: - claims - disputes - totals + CircuitOpenErrorBody: + type: object + properties: + error: + type: object + properties: + code: + type: string + enum: + - service_unavailable + message: + type: string + retryAfterMs: + type: integer + minimum: 0 + requestId: + type: string + required: + - code + - message + - retryAfterMs + - requestId + required: + - error AuditEntry: type: object properties: @@ -2485,6 +2509,12 @@ paths: - path: - expectedVersion message: expectedVersion is required + '403': + description: Forbidden — caller is not an administrator + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '404': description: Not found content: @@ -2843,6 +2873,12 @@ paths: required: - data - meta + '400': + description: Invalid pagination parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' /api/leaderboard/user/{stellarAddress}: get: operationId: getLeaderboardUser @@ -4165,6 +4201,71 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + /api/admin/users/{address}/impersonate: + post: + operationId: impersonateUser + tags: + - Admin + summary: Generate an impersonation JWT for a user (admin only) + description: >- + Admin-only endpoint that creates an audit-logged JWT allowing the caller to act as the target user. The + generated token carries a `user` role assertion. + + + Downstream work (audit-log writes and token signing) is wrapped in a per-endpoint circuit breaker. After + repeated downstream failures the breaker opens and the endpoint fast-fails with 503 without attempting any + downstream call, until a recovery probe succeeds. + security: + - bearerAuth: [] + parameters: + - schema: + type: string + required: true + name: address + in: path + responses: + '200': + description: Impersonation token + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + token: + type: string + required: + - token + required: + - data + '400': + description: Validation error — address is blank or whitespace-only + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden — missing, invalid, or non-admin JWT + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '429': + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '503': + description: >- + Circuit breaker is open — downstream dependencies are unhealthy and no downstream call was attempted. Retry + after `retryAfterMs`. + content: + application/json: + schema: + $ref: '#/components/schemas/CircuitOpenErrorBody' /api/admin/audit: get: operationId: getAdminAuditLog diff --git a/scripts/check-openapi.ts b/scripts/check-openapi.ts index d943a11e..6efe453f 100644 --- a/scripts/check-openapi.ts +++ b/scripts/check-openapi.ts @@ -1,3 +1,6 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as yaml from "js-yaml"; import { resetOpenApiCache, getOpenApiSpec } from "../src/openapi/builder"; type Method = "get" | "post" | "put" | "patch" | "delete" | "head" | "options"; @@ -31,6 +34,7 @@ const EXPECTED_ROUTES: RouteEntry[] = [ { method: "get", path: "/api/admin/audit" }, { method: "get", path: "/api/audit/counts" }, { method: "get", path: "/api/admin/users/{address}" }, + { method: "post", path: "/api/admin/users/{address}/impersonate" }, { method: "get", path: "/api/admin/feature-flags" }, { method: "post", path: "/api/admin/feature-flags" }, { method: "get", path: "/api/admin/feature-flags/{key}" }, @@ -150,8 +154,45 @@ function main(): number { exitCode = 1; } + // The checked-in YAML must be byte-for-byte reproducible from the registry. + // This catches manual edits and stale generated artifacts before deployment. + const generated = yaml.dump(spec, { + indent: 2, + lineWidth: 120, + noRefs: false, + sortKeys: false, + }); + const artifactPath = path.resolve(__dirname, "..", "openapi.yaml"); + const checkedIn = fs.readFileSync(artifactPath, "utf8"); + if (generated !== checkedIn) { + console.error("FAIL: openapi.yaml is stale; run npm run openapi:generate and commit the result"); + exitCode = 1; + } + + // Representative contract invariants: paginated endpoints must describe + // both cursor/limit inputs and a validation error, while protected routes + // must carry the bearer security requirement. + const paths = spec.paths as Record>; + for (const route of ["/api/users", "/api/users/{address}/predictions"]) { + const operation = paths[route]?.get; + const parameterNames = new Set((operation?.parameters ?? []).map((p: any) => p.name)); + if (!parameterNames.has("cursor") || !parameterNames.has("limit") || !operation?.responses?.["400"]) { + console.error(`FAIL: ${route} must document cursor, limit, and a 400 validation response`); + exitCode = 1; + } + } + for (const [route, item] of Object.entries(paths)) { + for (const [method, operation] of Object.entries(item)) { + if (!["get", "post", "put", "patch", "delete"].includes(method)) continue; + if (operation.security && operation.security.length > 0 && !operation.responses?.["401"] && !operation.responses?.["403"]) { + console.error(`FAIL: protected ${method.toUpperCase()} ${route} must document an auth error response`); + exitCode = 1; + } + } + } + if (exitCode === 0) { - console.log(`OK: all ${EXPECTED_ROUTES.length} routes documented correctly`); + console.log(`OK: routes, reproducible artifact, and representative contracts validated`); } return exitCode; diff --git a/src/openapi/registry.ts b/src/openapi/registry.ts index f508cfe8..802e793c 100644 --- a/src/openapi/registry.ts +++ b/src/openapi/registry.ts @@ -1032,6 +1032,10 @@ registry.registerPath({ }, }, }, + 403: { + description: "Forbidden — caller is not an administrator", + content: { "application/json": { schema: ErrorBody } }, + }, 404: { description: "Not found", content: { @@ -1427,6 +1431,10 @@ registry.registerPath({ }, }, }, + 400: { + description: "Invalid pagination parameters", + content: { "application/json": { schema: ErrorBody } }, + }, }, });