Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
101 changes: 101 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
43 changes: 42 additions & 1 deletion scripts/check-openapi.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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}" },
Expand Down Expand Up @@ -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<string, Record<string, any>>;
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;
Expand Down
8 changes: 8 additions & 0 deletions src/openapi/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,10 @@ registry.registerPath({
},
},
},
403: {
description: "Forbidden — caller is not an administrator",
content: { "application/json": { schema: ErrorBody } },
},
404: {
description: "Not found",
content: {
Expand Down Expand Up @@ -1427,6 +1431,10 @@ registry.registerPath({
},
},
},
400: {
description: "Invalid pagination parameters",
content: { "application/json": { schema: ErrorBody } },
},
},
});

Expand Down
Loading