Skip to content

Commit 9ee6ef2

Browse files
Merge pull request #913 from mikewheeleer/codex/issue-904-predictify-openapi
[#904] Enforce generated OpenAPI drift checks
2 parents 680a398 + 2ee2b60 commit 9ee6ef2

4 files changed

Lines changed: 154 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ jobs:
3939
- run: npm ci
4040
continue-on-error: true
4141

42+
- name: Check OpenAPI artifact and contract drift
43+
run: npm run openapi:check
44+
4245
- run: npm run lint || true
4346
continue-on-error: true
4447

openapi.yaml

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,6 +1138,30 @@ components:
11381138
- claims
11391139
- disputes
11401140
- totals
1141+
CircuitOpenErrorBody:
1142+
type: object
1143+
properties:
1144+
error:
1145+
type: object
1146+
properties:
1147+
code:
1148+
type: string
1149+
enum:
1150+
- service_unavailable
1151+
message:
1152+
type: string
1153+
retryAfterMs:
1154+
type: integer
1155+
minimum: 0
1156+
requestId:
1157+
type: string
1158+
required:
1159+
- code
1160+
- message
1161+
- retryAfterMs
1162+
- requestId
1163+
required:
1164+
- error
11411165
AuditEntry:
11421166
type: object
11431167
properties:
@@ -2485,6 +2509,12 @@ paths:
24852509
- path:
24862510
- expectedVersion
24872511
message: expectedVersion is required
2512+
'403':
2513+
description: Forbidden — caller is not an administrator
2514+
content:
2515+
application/json:
2516+
schema:
2517+
$ref: '#/components/schemas/ErrorBody'
24882518
'404':
24892519
description: Not found
24902520
content:
@@ -2843,6 +2873,12 @@ paths:
28432873
required:
28442874
- data
28452875
- meta
2876+
'400':
2877+
description: Invalid pagination parameters
2878+
content:
2879+
application/json:
2880+
schema:
2881+
$ref: '#/components/schemas/ErrorBody'
28462882
/api/leaderboard/user/{stellarAddress}:
28472883
get:
28482884
operationId: getLeaderboardUser
@@ -4165,6 +4201,71 @@ paths:
41654201
application/json:
41664202
schema:
41674203
$ref: '#/components/schemas/ErrorBody'
4204+
/api/admin/users/{address}/impersonate:
4205+
post:
4206+
operationId: impersonateUser
4207+
tags:
4208+
- Admin
4209+
summary: Generate an impersonation JWT for a user (admin only)
4210+
description: >-
4211+
Admin-only endpoint that creates an audit-logged JWT allowing the caller to act as the target user. The
4212+
generated token carries a `user` role assertion.
4213+
4214+
4215+
Downstream work (audit-log writes and token signing) is wrapped in a per-endpoint circuit breaker. After
4216+
repeated downstream failures the breaker opens and the endpoint fast-fails with 503 without attempting any
4217+
downstream call, until a recovery probe succeeds.
4218+
security:
4219+
- bearerAuth: []
4220+
parameters:
4221+
- schema:
4222+
type: string
4223+
required: true
4224+
name: address
4225+
in: path
4226+
responses:
4227+
'200':
4228+
description: Impersonation token
4229+
content:
4230+
application/json:
4231+
schema:
4232+
type: object
4233+
properties:
4234+
data:
4235+
type: object
4236+
properties:
4237+
token:
4238+
type: string
4239+
required:
4240+
- token
4241+
required:
4242+
- data
4243+
'400':
4244+
description: Validation error — address is blank or whitespace-only
4245+
content:
4246+
application/json:
4247+
schema:
4248+
$ref: '#/components/schemas/ErrorBody'
4249+
'403':
4250+
description: Forbidden — missing, invalid, or non-admin JWT
4251+
content:
4252+
application/json:
4253+
schema:
4254+
$ref: '#/components/schemas/ErrorBody'
4255+
'429':
4256+
description: Rate limit exceeded
4257+
content:
4258+
application/json:
4259+
schema:
4260+
$ref: '#/components/schemas/ErrorBody'
4261+
'503':
4262+
description: >-
4263+
Circuit breaker is open — downstream dependencies are unhealthy and no downstream call was attempted. Retry
4264+
after `retryAfterMs`.
4265+
content:
4266+
application/json:
4267+
schema:
4268+
$ref: '#/components/schemas/CircuitOpenErrorBody'
41684269
/api/admin/audit:
41694270
get:
41704271
operationId: getAdminAuditLog

scripts/check-openapi.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import * as fs from "fs";
2+
import * as path from "path";
3+
import * as yaml from "js-yaml";
14
import { resetOpenApiCache, getOpenApiSpec } from "../src/openapi/builder";
25

36
type Method = "get" | "post" | "put" | "patch" | "delete" | "head" | "options";
@@ -31,6 +34,7 @@ const EXPECTED_ROUTES: RouteEntry[] = [
3134
{ method: "get", path: "/api/admin/audit" },
3235
{ method: "get", path: "/api/audit/counts" },
3336
{ method: "get", path: "/api/admin/users/{address}" },
37+
{ method: "post", path: "/api/admin/users/{address}/impersonate" },
3438
{ method: "get", path: "/api/admin/feature-flags" },
3539
{ method: "post", path: "/api/admin/feature-flags" },
3640
{ method: "get", path: "/api/admin/feature-flags/{key}" },
@@ -150,8 +154,45 @@ function main(): number {
150154
exitCode = 1;
151155
}
152156

157+
// The checked-in YAML must be byte-for-byte reproducible from the registry.
158+
// This catches manual edits and stale generated artifacts before deployment.
159+
const generated = yaml.dump(spec, {
160+
indent: 2,
161+
lineWidth: 120,
162+
noRefs: false,
163+
sortKeys: false,
164+
});
165+
const artifactPath = path.resolve(__dirname, "..", "openapi.yaml");
166+
const checkedIn = fs.readFileSync(artifactPath, "utf8");
167+
if (generated !== checkedIn) {
168+
console.error("FAIL: openapi.yaml is stale; run npm run openapi:generate and commit the result");
169+
exitCode = 1;
170+
}
171+
172+
// Representative contract invariants: paginated endpoints must describe
173+
// both cursor/limit inputs and a validation error, while protected routes
174+
// must carry the bearer security requirement.
175+
const paths = spec.paths as Record<string, Record<string, any>>;
176+
for (const route of ["/api/users", "/api/users/{address}/predictions"]) {
177+
const operation = paths[route]?.get;
178+
const parameterNames = new Set((operation?.parameters ?? []).map((p: any) => p.name));
179+
if (!parameterNames.has("cursor") || !parameterNames.has("limit") || !operation?.responses?.["400"]) {
180+
console.error(`FAIL: ${route} must document cursor, limit, and a 400 validation response`);
181+
exitCode = 1;
182+
}
183+
}
184+
for (const [route, item] of Object.entries(paths)) {
185+
for (const [method, operation] of Object.entries(item)) {
186+
if (!["get", "post", "put", "patch", "delete"].includes(method)) continue;
187+
if (operation.security && operation.security.length > 0 && !operation.responses?.["401"] && !operation.responses?.["403"]) {
188+
console.error(`FAIL: protected ${method.toUpperCase()} ${route} must document an auth error response`);
189+
exitCode = 1;
190+
}
191+
}
192+
}
193+
153194
if (exitCode === 0) {
154-
console.log(`OK: all ${EXPECTED_ROUTES.length} routes documented correctly`);
195+
console.log(`OK: routes, reproducible artifact, and representative contracts validated`);
155196
}
156197

157198
return exitCode;

src/openapi/registry.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,6 +1032,10 @@ registry.registerPath({
10321032
},
10331033
},
10341034
},
1035+
403: {
1036+
description: "Forbidden — caller is not an administrator",
1037+
content: { "application/json": { schema: ErrorBody } },
1038+
},
10351039
404: {
10361040
description: "Not found",
10371041
content: {
@@ -1427,6 +1431,10 @@ registry.registerPath({
14271431
},
14281432
},
14291433
},
1434+
400: {
1435+
description: "Invalid pagination parameters",
1436+
content: { "application/json": { schema: ErrorBody } },
1437+
},
14301438
},
14311439
});
14321440

0 commit comments

Comments
 (0)