From 568795afadaf9a4f33270fc67923287777825fbb Mon Sep 17 00:00:00 2001 From: Umeokonkwo Samuel Date: Fri, 28 Aug 2026 13:58:18 +0100 Subject: [PATCH] feat: Enforce OpenAPI artifact drift checks for every route (Closes #938) --- openapi.yaml | 46 +++- scripts/check-openapi.ts | 285 +++++++++++++++------ src/__tests__/routes/subscriptions.test.ts | 23 +- src/index.ts | 14 +- src/metrics/registry.ts | 23 ++ src/middleware/idempotency.ts | 2 +- src/openapi/registry.ts | 45 +++- src/routes/alerts.test.ts | 4 +- src/routes/markets/predictions.ts | 2 +- src/routes/predictions.ts | 79 +++--- src/routes/subscriptions.ts | 4 +- src/server.ts | 9 +- src/services/indexerService.ts | 2 +- src/services/referralService.ts | 3 +- src/services/userService.ts | 2 +- src/workers/predictionsConfirmer.ts | 4 +- tests/openapi.drift.test.ts | 193 ++++++++++++++ 17 files changed, 569 insertions(+), 171 deletions(-) create mode 100644 tests/openapi.drift.test.ts diff --git a/openapi.yaml b/openapi.yaml index 7d7fe6b9..0966f7a7 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -971,19 +971,19 @@ components: PredictionsListResponse: type: object properties: - items: + data: type: array items: $ref: '#/components/schemas/PredictionRow' - next_cursor: + nextCursor: type: string nullable: true total: type: integer minimum: 0 required: - - items - - next_cursor + - data + - nextCursor FollowResult: type: object properties: @@ -2113,6 +2113,12 @@ paths: version: 1 createdAt: '2026-03-01T09:00:00.000Z' nextCursor: null + '400': + description: Validation error — invalid query parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorBody' '401': description: Unauthorized content: @@ -2166,6 +2172,12 @@ paths: required: - data - nextCursor + '400': + description: Validation error — invalid query parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorBody' '401': description: Unauthorized content: @@ -3913,7 +3925,7 @@ paths: summary: List the authenticated user’s predictions description: >- Returns a cursor-paginated list of predictions placed by the caller. Sort order is `createdAt DESC, id DESC`. - Pass the returned `next_cursor` as `?cursor=` to fetch the next page. `next_cursor` is `null` when no further + Pass the returned `nextCursor` as `?cursor=` to fetch the next page. `nextCursor` is `null` when no further pages exist. security: - bearerAuth: [] @@ -3961,7 +3973,7 @@ paths: examples: authenticatedPredictionsPage: value: - items: + data: - id: f47ac10b-58cc-4372-a567-0e02b2c3d479 marketId: market_123 question: Will Bitcoin hit 100k in 2026? @@ -3972,7 +3984,7 @@ paths: result: 'yes' createdAt: '2026-05-01T12:00:00.000Z' resolutionTime: '2026-06-01T12:00:00.000Z' - next_cursor: cursor_abc123 + nextCursor: cursor_abc123 '400': description: Validation error — invalid query parameters content: @@ -4139,6 +4151,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AdminRouteListResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorBody' '403': description: Forbidden content: @@ -4749,6 +4767,14 @@ paths: description: Returns the current sliding-window rate-limit usage for a target Stellar address. Admin-only and read-only. security: - bearerAuth: [] + parameters: + - schema: + type: string + description: Target Stellar address + required: true + description: Target Stellar address + name: address + in: path responses: '200': description: Current rate-limit state for the requested address @@ -5070,6 +5096,12 @@ paths: value: data: [] nextCursor: null + '400': + description: Validation error — invalid query parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorBody' '401': description: Missing or invalid JWT content: diff --git a/scripts/check-openapi.ts b/scripts/check-openapi.ts index 6efe453f..ab2a7ad7 100644 --- a/scripts/check-openapi.ts +++ b/scripts/check-openapi.ts @@ -3,14 +3,14 @@ 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"; +export type Method = "get" | "post" | "put" | "patch" | "delete" | "head" | "options"; -interface RouteEntry { +export interface RouteEntry { method: Method; path: string; } -const EXPECTED_ROUTES: RouteEntry[] = [ +export const EXPECTED_ROUTES: RouteEntry[] = [ { method: "get", path: "/health" }, { method: "get", path: "/healthz/dependencies" }, { method: "get", path: "/metrics" }, @@ -80,122 +80,261 @@ const EXPECTED_ROUTES: RouteEntry[] = [ { method: "post", path: "/api/referrals" }, ]; -function key(route: RouteEntry): string { +export function routeKey(route: RouteEntry): string { return `${route.method.toUpperCase()} ${route.path}`; } -function main(): number { - resetOpenApiCache(); - const spec = getOpenApiSpec(); +export interface ValidationResult { + valid: boolean; + errors: string[]; +} - let exitCode = 0; +/** + * Normalizes line endings so comparisons are deterministic across operating systems. + */ +export function normalizeLineEndings(str: string): string { + return str.replace(/\r\n/g, "\n").trim(); +} + +/** + * Validates OpenAPI document top-level structure. + */ +export function validateStructure(spec: any): ValidationResult { + const errors: string[] = []; + + if (!spec || typeof spec !== "object") { + return { valid: false, errors: ["Spec is not an object or is empty"] }; + } - // 1. Basic structural validation if (typeof spec.openapi !== "string" || !spec.openapi.startsWith("3.")) { - console.error("FAIL: openapi version is missing or not 3.x"); - exitCode = 1; + errors.push("openapi version is missing or not 3.x (expected 3.1.0)"); } - if (!spec.info) { - console.error("FAIL: info section is missing"); - exitCode = 1; + if (!spec.info || typeof spec.info !== "object") { + errors.push("info section is missing"); + } else { + if (!spec.info.title) errors.push("info.title is missing"); + if (!spec.info.version) errors.push("info.version is missing"); } - if (!spec.paths || Object.keys(spec.paths).length === 0) { - console.error("FAIL: no paths defined"); - exitCode = 1; + if (!spec.paths || typeof spec.paths !== "object" || Object.keys(spec.paths).length === 0) { + errors.push("no paths defined in spec"); } - // 2. Collect documented routes + return { valid: errors.length === 0, errors }; +} + +/** + * Validates route coverage and detects missing or extra routes. + */ +export function validateRouteCoverage( + spec: any, + expectedRoutes: RouteEntry[] = EXPECTED_ROUTES, +): ValidationResult { + const errors: string[] = []; const documented = new Set(); - for (const [pathStr, pathItem] of Object.entries(spec.paths ?? {})) { - const methods = ["get", "post", "put", "patch", "delete"] as Method[]; + for (const [pathStr, pathItem] of Object.entries(spec?.paths ?? {})) { + const methods = ["get", "post", "put", "patch", "delete", "head", "options"] as Method[]; for (const method of methods) { - const op = (pathItem as Record)[method] as - | Record - | undefined; + const op = (pathItem as Record)?.[method]; if (op) { - documented.add(key({ method, path: pathStr })); + documented.add(routeKey({ method, path: pathStr })); } } } - // 3. Check for missing routes - const expectedSet = new Set(EXPECTED_ROUTES.map(key)); - const missing: string[] = []; - const extra: string[] = []; + const expectedSet = new Set(expectedRoutes.map(routeKey)); - for (const route of EXPECTED_ROUTES) { - if (!documented.has(key(route))) { - missing.push(key(route)); + for (const route of expectedRoutes) { + const k = routeKey(route); + if (!documented.has(k)) { + errors.push(`MISSING route from OpenAPI spec: ${k}`); } } for (const doc of documented) { if (!expectedSet.has(doc)) { - extra.push(doc); + errors.push(`EXTRA undocumented route found in OpenAPI spec: ${doc}`); } } - if (missing.length > 0) { - console.error("FAIL: routes missing from OpenAPI spec:"); - for (const r of missing) { - console.error(` MISSING ${r}`); - } - exitCode = 1; - } + return { valid: errors.length === 0, errors }; +} - if (extra.length > 0) { - console.error("FAIL: undocumented routes found in spec (not in Express):"); - for (const r of extra) { - console.error(` EXTRA ${r}`); - } - exitCode = 1; +/** + * Validates that the checked-in openapi.yaml artifact matches the generated spec. + */ +export function validateArtifactDrift( + spec: any, + artifactPath = path.resolve(__dirname, "..", "openapi.yaml"), +): ValidationResult { + const errors: string[] = []; + + if (!fs.existsSync(artifactPath)) { + return { + valid: false, + errors: [`OpenAPI artifact file not found at ${artifactPath}`], + }; } - // 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; - } + if (normalizeLineEndings(generated) !== normalizeLineEndings(checkedIn)) { + errors.push( + "openapi.yaml is stale or does not match generated registry spec; run `npm run openapi:generate` and commit the result", + ); } - 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; + + return { valid: errors.length === 0, errors }; +} + +/** + * Validates per-route contract invariants across all operations in the spec. + */ +export function validateRouteInvariants(spec: any): ValidationResult { + const errors: string[] = []; + const paths = (spec?.paths ?? {}) as Record>; + const operationIds = new Set(); + const methods = ["get", "post", "put", "patch", "delete"] as const; + + for (const [pathStr, pathItem] of Object.entries(paths)) { + const pathParamsInUrl = Array.from(pathStr.matchAll(/\{([^}]+)\}/g)).map((m) => m[1]); + + for (const method of methods) { + const op = pathItem[method]; + if (!op) continue; + + const opKey = `${method.toUpperCase()} ${pathStr}`; + + // 1. Operation ID presence & uniqueness + if (!op.operationId || typeof op.operationId !== "string" || op.operationId.trim() === "") { + errors.push(`${opKey}: missing or invalid operationId`); + } else { + if (operationIds.has(op.operationId)) { + errors.push(`${opKey}: duplicate operationId "${op.operationId}"`); + } + operationIds.add(op.operationId); + } + + // 2. Tags + if (!op.tags || !Array.isArray(op.tags) || op.tags.length === 0) { + errors.push(`${opKey}: missing tags array`); + } + + // 3. Summary or Description + if (!op.summary && !op.description) { + errors.push(`${opKey}: missing summary and description`); + } + + // 4. Response definitions + if (!op.responses || typeof op.responses !== "object" || Object.keys(op.responses).length === 0) { + errors.push(`${opKey}: no responses defined`); + } else { + const statusCodes = Object.keys(op.responses); + const hasSuccess = statusCodes.some((code) => code.startsWith("2") || code === "304"); + if (!hasSuccess) { + errors.push(`${opKey}: missing 2xx or 304 success response definition`); + } + + // Security requirement check: protected routes must document 401 or 403 + if (op.security && Array.isArray(op.security) && op.security.length > 0) { + const hasAuthError = statusCodes.includes("401") || statusCodes.includes("403"); + if (!hasAuthError) { + errors.push(`${opKey}: protected route must document an auth error response (401/403)`); + } + } + } + + // 5. Path parameter parity + if (pathParamsInUrl.length > 0) { + const declaredParams = (op.parameters ?? []) + .filter((p: any) => p.in === "path") + .map((p: any) => p.name); + + for (const paramName of pathParamsInUrl) { + if (!declaredParams.includes(paramName)) { + errors.push( + `${opKey}: path parameter '{${paramName}}' in URL is missing from operation parameters`, + ); + } + } + } + + // 6. Paginated route contract invariants + const paramNames = new Set((op.parameters ?? []).map((p: any) => p.name)); + const isPaginated = paramNames.has("cursor") || paramNames.has("limit"); + if (isPaginated) { + if (!op.responses?.["400"]) { + errors.push(`${opKey}: paginated endpoint must document a 400 validation error response`); + } } } } - if (exitCode === 0) { - console.log(`OK: routes, reproducible artifact, and representative contracts validated`); + return { valid: errors.length === 0, errors }; +} + +/** + * Runs all OpenAPI drift and invariant checks. + */ +export function checkOpenApi( + spec = getOpenApiSpec(), + expectedRoutes: RouteEntry[] = EXPECTED_ROUTES, + artifactPath = path.resolve(__dirname, "..", "openapi.yaml"), +): { success: boolean; errors: string[] } { + const allErrors: string[] = []; + + const structResult = validateStructure(spec); + if (!structResult.valid) { + allErrors.push(...structResult.errors.map((e) => `[STRUCTURE] ${e}`)); + } + + const coverageResult = validateRouteCoverage(spec, expectedRoutes); + if (!coverageResult.valid) { + allErrors.push(...coverageResult.errors.map((e) => `[ROUTE DRIFT] ${e}`)); } - return exitCode; + const artifactResult = validateArtifactDrift(spec, artifactPath); + if (!artifactResult.valid) { + allErrors.push(...artifactResult.errors.map((e) => `[ARTIFACT DRIFT] ${e}`)); + } + + const invariantResult = validateRouteInvariants(spec); + if (!invariantResult.valid) { + allErrors.push(...invariantResult.errors.map((e) => `[INVARIANT] ${e}`)); + } + + return { + success: allErrors.length === 0, + errors: allErrors, + }; } -process.exit(main()); +export function main(): number { + resetOpenApiCache(); + const spec = getOpenApiSpec(); + const { success, errors } = checkOpenApi(spec); + + if (!success) { + console.error(`FAIL: OpenAPI drift checks failed with ${errors.length} issue(s):`); + for (const err of errors) { + console.error(` ${err}`); + } + return 1; + } + + console.log("OK: All routes, reproducible OpenAPI artifact, and contract invariants validated successfully."); + return 0; +} + +if (require.main === module) { + process.exit(main()); +} diff --git a/src/__tests__/routes/subscriptions.test.ts b/src/__tests__/routes/subscriptions.test.ts index 76adaac4..782f555e 100644 --- a/src/__tests__/routes/subscriptions.test.ts +++ b/src/__tests__/routes/subscriptions.test.ts @@ -28,6 +28,7 @@ import { eventTypeSchema, webhookUrlSchema, } from "../../validators/subscriptions"; +import { createAuditLog } from "../../services/auditService"; jest.mock("../../services/auditService", () => ({ createAuditLog: jest.fn().mockResolvedValue("corr-id"), @@ -71,13 +72,6 @@ const mockSubscription = { updatedAt: new Date("2026-01-01T00:00:00.000Z"), }; -// Public-facing serialisation omits the secret field -const publicSubscription = (() => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { secret: _s, ...pub } = mockSubscription; - return pub; -})(); - // --------------------------------------------------------------------------- // App factory // --------------------------------------------------------------------------- @@ -482,7 +476,6 @@ describe("Mutations", () => { expect(response.status).toBe(201); expect(response.body.data).toEqual(JSON.parse(JSON.stringify(newRow))); - const { createAuditLog } = require("../../services/auditService"); expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "admin.subscription.create", entityType: "Subscription", @@ -505,7 +498,6 @@ describe("Mutations", () => { expect(response.status).toBe(200); expect(response.body.data).toEqual(JSON.parse(JSON.stringify(updated))); - const { createAuditLog } = require("../../services/auditService"); expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "admin.subscription.update", entityType: "Subscription", @@ -524,7 +516,6 @@ describe("Mutations", () => { const response = await request(app).delete(`/api/subscriptions/${existing.id}`); expect(response.status).toBe(204); - const { createAuditLog } = require("../../services/auditService"); expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "admin.subscription.delete", entityType: "Subscription", @@ -542,7 +533,6 @@ describe("Mutations", () => { await request(app).post("/api/subscriptions").send({ url: newRow.url, events: newRow.events }); - const { createAuditLog } = require("../../services/auditService"); expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ correlationId: expect.any(String), })); @@ -556,7 +546,6 @@ describe("Mutations", () => { await request(app).post("/api/subscriptions").send({ url: newRow.url, events: newRow.events }); - const { createAuditLog } = require("../../services/auditService"); expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ metadata: expect.objectContaining({ endpoint: "/api/subscriptions" }), })); @@ -573,8 +562,7 @@ describe("Mutations", () => { await request(app).patch(`/api/subscriptions/${existing.id}`).send({ url: updated.url }); - const { createAuditLog } = require("../../services/auditService"); - const callArgs = createAuditLog.mock.calls[0][0]; + const callArgs = (createAuditLog as jest.Mock).mock.calls[0][0] as { beforeState: { secret: string } }; expect(callArgs.beforeState.secret).toBe("[REDACTED]"); }); @@ -589,8 +577,7 @@ describe("Mutations", () => { await request(app).patch(`/api/subscriptions/${existing.id}`).send({ url: updated.url }); - const { createAuditLog } = require("../../services/auditService"); - const callArgs = createAuditLog.mock.calls[0][0]; + const callArgs = (createAuditLog as jest.Mock).mock.calls[0][0] as { afterState: { secret: string } }; expect(callArgs.afterState.secret).toBe("[REDACTED]"); }); @@ -599,14 +586,12 @@ describe("Mutations", () => { await request(app).get("/api/subscriptions"); - const { createAuditLog } = require("../../services/auditService"); expect(createAuditLog).not.toHaveBeenCalled(); }); it("POST /api/subscriptions does not audit on validation failure", async () => { await request(app).post("/api/subscriptions").send({ url: "http://not-https.com", events: [] }); - const { createAuditLog } = require("../../services/auditService"); expect(createAuditLog).not.toHaveBeenCalled(); }); @@ -615,7 +600,6 @@ describe("Mutations", () => { await request(app).patch("/api/subscriptions/123e4567-e89b-12d3-a456-426614174000").send({ url: "https://new.example.com" }); - const { createAuditLog } = require("../../services/auditService"); expect(createAuditLog).not.toHaveBeenCalled(); }); @@ -624,7 +608,6 @@ describe("Mutations", () => { await request(app).delete("/api/subscriptions/123e4567-e89b-12d3-a456-426614174000"); - const { createAuditLog } = require("../../services/auditService"); expect(createAuditLog).not.toHaveBeenCalled(); }); }); diff --git a/src/index.ts b/src/index.ts index 42614e05..554d08a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { fingerprintMiddleware } from "./middleware/fingerprint"; import { accessLog } from "./middleware/accessLog"; import { idempotency } from "./middleware/idempotency"; import { defaultBodySizeLimitMiddleware, webhookBodySizeLimitMiddleware } from "./middleware/bodySize"; +import { perUserConcurrency } from "./middleware/perUserConcurrency"; import { healthRouter } from "./routes/health"; import healthzDependenciesRouter from "./routes/healthz/dependencies"; import { createReadyRouter } from "./routes/health/ready"; @@ -19,7 +20,6 @@ import { dependenciesRouter } from "./routes/health/dependencies"; import { versionRouter } from "./routes/health/version"; import { redisConnection } from "./queue"; import { authRouter } from "./routes/auth"; -import { adminRouter } from "./routes/admin"; import { recommendationsRouter } from "./routes/recommendations"; import { recommendationsHealthRouter } from "./routes/recommendations/health"; import { tagsRouter } from "./routes/tags"; @@ -29,7 +29,6 @@ import { commentsRouter } from "./routes/comments"; import { usersRouter } from "./routes/users"; import { predictionsRouter } from "./routes/predictions"; import { usersHealthRouter } from "./routes/users/health"; -import { exportsPredictionsRouter } from "./routes/exports/predictions"; import { userPortfolioRouter } from "./routes/users/portfolio"; import { statsRouter } from "./routes/stats"; import { userStatsRouter } from "./routes/users/stats"; @@ -50,9 +49,7 @@ import { referralsRouter } from "./routes/referrals"; import { notificationsRouter } from "./routes/notifications"; import { notificationsHealthRouter } from "./routes/notifications/health"; import { socialRouter } from "./routes/social"; -import { webhooksRouter } from "./routes/webhooks"; import { webhooksHealthRouter } from "./routes/webhooks/health"; -import { createWebhooksRouter } from "./routes/webhooks"; import { adminAuditRouter } from "./routes/admin/audit"; import { adminAuditExportRouter } from "./routes/admin/audit/export"; import { auditCountsRouter } from "./routes/audit/counts"; @@ -67,7 +64,7 @@ import { REQUEST_ID_HEADER } from "./lib/http"; import { register } from "./metrics/registry"; import { connectWithRetry, closeDb, db } from "./db/client"; import { stopScheduler } from "./services/scheduler"; -import { startIndexerHealthProbe, stopIndexerHealthProbe } from "./jobs/indexerHealthProbe"; +import { startIndexerHealthProbe } from "./jobs/indexerHealthProbe"; import { indexerHealthRouter } from "./routes/indexer/health"; import { indexerCursorRouter } from "./routes/indexer/cursor"; import { WebhookWorker } from "./workers/webhookWorker"; @@ -83,11 +80,8 @@ import { reportsRouter } from "./routes/reports"; import { exportsRouter } from "./routes/exports"; import { fingerprintRouter } from "./routes/fingerprint"; import { alertsRouter } from "./routes/alerts"; -import { gracefulShutdown } from "./lifecycle/shutdown"; -import { DrizzleWebhookStore } from "./services/drizzleWebhookStore"; -import type { IWebhookDispatcher } from "./services/webhookDispatcher"; -import type { WebhookStore } from "./services/webhookStore"; +export type CreateAppOptions = Record; const docsEnabled = process.env.ENABLE_DOCS === "true" || @@ -102,7 +96,7 @@ function sanitizeRequestId(raw: string): string | undefined { return sanitized.length > 0 ? sanitized : undefined; } -export function createApp(options: CreateAppOptions = {}): express.Express { +export function createApp(_options: CreateAppOptions = {}): express.Express { const app = express(); app.set("etag", false); diff --git a/src/metrics/registry.ts b/src/metrics/registry.ts index 5fbce17e..bf64025b 100644 --- a/src/metrics/registry.ts +++ b/src/metrics/registry.ts @@ -223,3 +223,26 @@ export const authEndpointDuration = new Histogram({ buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], registers: [register], }); + +export const predictionsListTotal = new Counter({ + name: "predictions_list_total", + help: "Total number of list predictions requests", + labelNames: ["outcome"] as const, + registers: [register], +}); + +export const predictionExplainTotal = new Counter({ + name: "prediction_explain_total", + help: "Total number of prediction explanation requests", + labelNames: ["outcome"] as const, + registers: [register], +}); + +export const predictionsRequestDuration = new Histogram({ + name: "predictions_request_duration_seconds", + help: "Request duration in seconds for prediction endpoints", + labelNames: ["handler", "outcome"] as const, + buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], + registers: [register], +}); + diff --git a/src/middleware/idempotency.ts b/src/middleware/idempotency.ts index 3e0e9fb7..bad5ad92 100644 --- a/src/middleware/idempotency.ts +++ b/src/middleware/idempotency.ts @@ -197,7 +197,7 @@ export async function idempotency( } else if (typeof body === "object" && body !== null) { saveIdempotency(body); } - return originalSend(body as any); + return originalSend(body as Parameters[0]); }; next(); diff --git a/src/openapi/registry.ts b/src/openapi/registry.ts index 802e793c..263d8c97 100644 --- a/src/openapi/registry.ts +++ b/src/openapi/registry.ts @@ -599,6 +599,14 @@ registry.registerPath({ }, }, }, + 400: { + description: "Validation error — invalid query parameters", + content: { + "application/json": { + schema: ValidationErrorBody, + }, + }, + }, 401: { description: "Unauthorized", content: { @@ -639,6 +647,14 @@ registry.registerPath({ }, }, }, + 400: { + description: "Validation error — invalid query parameters", + content: { + "application/json": { + schema: ValidationErrorBody, + }, + }, + }, 401: { description: "Unauthorized", content: { @@ -2588,9 +2604,9 @@ const PredictionRow = z const PredictionsListResponse = z .object({ - items: z.array(PredictionRow), + data: z.array(PredictionRow), /** Opaque cursor for the next page, or null if this is the last page. */ - next_cursor: z.string().nullable(), + nextCursor: z.string().nullable(), /** Optional total count for clients that need it. */ total: z.number().int().nonnegative().optional(), }) @@ -2613,12 +2629,12 @@ registry.registerPath({ path: "/api/predictions", operationId: "listPredictions", tags: ["Predictions"], - summary: "List the authenticated user\u2019s predictions", + summary: "List the authenticated user’s predictions", description: "Returns a cursor-paginated list of predictions placed by the caller. " + "Sort order is `createdAt DESC, id DESC`. " + - "Pass the returned `next_cursor` as `?cursor=` to fetch the next page. " + - "`next_cursor` is `null` when no further pages exist.", + "Pass the returned `nextCursor` as `?cursor=` to fetch the next page. " + + "`nextCursor` is `null` when no further pages exist.", security: [{ bearerAuth: [] }], request: { query: z.object({ @@ -2628,7 +2644,7 @@ registry.registerPath({ status: PredictionStatus.optional(), /** Filter by chosen outcome value (e.g. "yes" / "no"). */ outcome: z.string().min(1).max(64).optional(), - /** Opaque cursor from the previous page’s `next_cursor`. */ + /** Opaque cursor from the previous page’s `nextCursor`. */ cursor: z.string().optional(), /** Page size — default 20, max 100. */ limit: z.coerce.number().int().min(1).max(100).default(20).optional(), @@ -2643,7 +2659,7 @@ registry.registerPath({ examples: { authenticatedPredictionsPage: { value: { - items: [ + data: [ { id: "f47ac10b-58cc-4372-a567-0e02b2c3d479", marketId: "market_123", @@ -2657,7 +2673,7 @@ registry.registerPath({ resolutionTime: "2026-06-01T12:00:00.000Z", }, ], - next_cursor: "cursor_abc123", + nextCursor: "cursor_abc123", }, }, }, @@ -2920,6 +2936,10 @@ registry.registerPath({ }, }, }, + 400: { + description: "Validation error", + content: { "application/json": { schema: ValidationErrorBody } }, + }, 403: { description: "Forbidden", content: { "application/json": { schema: ErrorBody } }, @@ -3438,6 +3458,11 @@ registry.registerPath({ "Returns the current sliding-window rate-limit usage for a target Stellar address. " + "Admin-only and read-only.", security: [{ bearerAuth: [] }], + request: { + params: z.object({ + address: z.string().describe("Target Stellar address"), + }), + }, responses: { 200: { description: "Current rate-limit state for the requested address", @@ -3884,6 +3909,10 @@ registry.registerPath({ }, }, }, + 400: { + description: "Validation error — invalid query parameters", + content: { "application/json": { schema: ValidationErrorBody } }, + }, 401: { description: "Missing or invalid JWT", content: { "application/json": { schema: ErrorBody } }, diff --git a/src/routes/alerts.test.ts b/src/routes/alerts.test.ts index 7840a289..07bd07a7 100644 --- a/src/routes/alerts.test.ts +++ b/src/routes/alerts.test.ts @@ -4,8 +4,8 @@ import { alertsRouter } from "./alerts"; import { errorHandler } from "../middleware/errorHandler"; jest.mock("../middleware/requireAuth", () => ({ - requireAuth: (req: any, _res: any, next: any) => { - req.user = { id: "user-123" }; + requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { + (req as { user?: { id: string } }).user = { id: "user-123" }; next(); }, })); diff --git a/src/routes/markets/predictions.ts b/src/routes/markets/predictions.ts index 9b66eee4..c57de3d4 100644 --- a/src/routes/markets/predictions.ts +++ b/src/routes/markets/predictions.ts @@ -153,7 +153,7 @@ predictionsRouter.get( res.status(200).json(payload); } catch (err) { - if (err instanceof Error && (err as any).status === 404) { + if (err instanceof Error && (err as { status?: number }).status === 404) { logger.warn({ reqId, marketId }, "market_predictions_list_not_found"); res.status(404).json({ error: { diff --git a/src/routes/predictions.ts b/src/routes/predictions.ts index 1c9ededf..09658f9d 100644 --- a/src/routes/predictions.ts +++ b/src/routes/predictions.ts @@ -19,10 +19,6 @@ import { Router, type Request, type Response, type NextFunction } from "express" import { z } from "zod"; import { requireAuth } from "../middleware/requireAuth"; import { claimWinnings, ClaimError } from "../services/claimService"; -import { Router, Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { requireAuth } from "../middleware/requireAuth"; -import { claimWinnings, ClaimError } from "../services/claimService"; import { logger } from "../config/logger"; import { getRequestId } from "../lib/requestContext"; import { createPerUserRateLimiter } from "../middleware/rateLimit"; @@ -32,8 +28,6 @@ import { createShareRouter } from "./predictions/share"; import { predictionsHealthRouter } from "./predictions/health"; import { listPredictions } from "../repositories/predictionRepo"; import { conditionalGet } from "../middleware/etag"; -import { logger } from "../config/logger"; -import { getRequestId } from "../lib/requestContext"; import { clampLimit } from "../utils/cursor"; import { predictionsListTotal, @@ -41,9 +35,8 @@ import { predictionsRequestDuration, } from "../metrics/registry"; import type { AuthenticatedRequest } from "../middleware/auth"; -import { listPredictionsQuerySchema } from "../validators/predictions"; +import { listPredictionsQuerySchema, predictionIdParamSchema } from "../validators/predictions"; import { requestTimeout } from "../middleware/timeout"; -import { conditionalGet } from "../middleware/etag"; export const predictionsRouter = Router(); @@ -63,6 +56,47 @@ predictionsRouter.use("/", createShareRouter()); predictionsRouter.use("/", cancelRouter); predictionsRouter.use("/", predictionsHealthRouter); +/** + * GET /api/predictions/:id/explain + * Returns the resolution computation trail for a prediction (educational endpoint). + * Shows oracle inputs, market resolution, and payout calculation. + * Public — no authentication required. + */ +predictionsRouter.get("/:id/explain", async (req, res, next) => { + const startMs = Date.now(); + try { + const paramsResult = predictionIdParamSchema.safeParse(req.params); + if (!paramsResult.success) { + predictionExplainTotal.inc({ outcome: "error" }); + return res.status(400).json({ + error: { + code: "validation_error", + message: paramsResult.error.issues[0]?.message ?? "Invalid prediction ID", + requestId: getRequestId(), + }, + }); + } + const { id } = paramsResult.data; + const explanation = await getPredictionExplanation(id); + predictionExplainTotal.inc({ outcome: "success" }); + predictionsRequestDuration.observe( + { handler: "explain", outcome: "success" }, + (Date.now() - startMs) / 1000, + ); + if (conditionalGet(explanation, req, res)) return; + res.json(explanation); + } catch (error) { + predictionExplainTotal.inc({ outcome: "error" }); + predictionsRequestDuration.observe( + { handler: "explain", outcome: "error" }, + (Date.now() - startMs) / 1000, + ); + next(error); + } +}); + + + // ── Authenticated routes ────────────────────────────────────────────────── predictionsRouter.use(requireAuth); predictionsRouter.use( @@ -233,7 +267,7 @@ predictionsRouter.get( cursor, }); - const payload = { items: page.data, next_cursor: page.nextCursor }; + const payload = { data: page.data, nextCursor: page.nextCursor }; if (conditionalGet(payload, req, res)) return; logger.info( @@ -252,7 +286,7 @@ predictionsRouter.get( (Date.now() - startMs) / 1000, ); - res.json({ items: page.data, next_cursor: page.nextCursor }); + res.json(payload); } catch (err) { predictionsListTotal.inc({ outcome: "error" }); predictionsRequestDuration.observe( @@ -264,28 +298,3 @@ predictionsRouter.get( }, ); -/** - * GET /api/predictions/:id/explain - * Returns the resolution computation trail for a prediction (educational endpoint). - * Shows oracle inputs, market resolution, and payout calculation. - */ -predictionsRouter.get("/:id/explain", async (req, res, next) => { - const startMs = Date.now(); - try { - const { id } = req.params; - const explanation = await getPredictionExplanation(id); - predictionExplainTotal.inc({ outcome: "success" }); - predictionsRequestDuration.observe( - { handler: "explain", outcome: "success" }, - (Date.now() - startMs) / 1000, - ); - res.json(explanation); - } catch (error) { - predictionExplainTotal.inc({ outcome: "error" }); - predictionsRequestDuration.observe( - { handler: "explain", outcome: "error" }, - (Date.now() - startMs) / 1000, - ); - next(error); - } -}); diff --git a/src/routes/subscriptions.ts b/src/routes/subscriptions.ts index 5931e802..1a6f2763 100644 --- a/src/routes/subscriptions.ts +++ b/src/routes/subscriptions.ts @@ -34,7 +34,7 @@ import { patchSubscriptionBodySchema, subscriptionIdParamSchema, } from "../validators/subscriptions"; -import { createAuditLog, sanitizeState } from "../services/auditService"; +import { createAuditLog } from "../services/auditService"; import { getCorrelationId } from "../middleware/correlation"; export const subscriptionsRouter = Router(); @@ -264,7 +264,7 @@ subscriptionsRouter.delete("/:id", async (req, res, next) => { throw RouteErrorFactory.notFound("Subscription not found"); } - const result = await db + await db .delete(webhookSubscriptions) .where(eq(webhookSubscriptions.id, id)); diff --git a/src/server.ts b/src/server.ts index 4e15b6ec..9473c70b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,20 +16,17 @@ import { drainFingerprintRequests } from "./routes/fingerprint"; import { drainReportsRequests } from "./routes/reports"; const app = createApp(); -let webhookWorker: WebhookWorker | null = null; -let probeHandle: ReturnType | null = null; -let predictionsConfirmerHandle: ReturnType | null = null; connectWithRetry() .then(() => { - webhookWorker = new WebhookWorker(db); + const webhookWorker = new WebhookWorker(db); webhookWorker.start(); marketResolverWorker.start(); backupVerificationWorker.start(); reconciliationWorker.start(); startSlowQueryAlerter(); - predictionsConfirmerHandle = startPredictionsConfirmer(); - probeHandle = startIndexerHealthProbe(); + startPredictionsConfirmer(); + startIndexerHealthProbe(); const server = app.listen(env.PORT, () => { logger.info({ port: env.PORT, env: env.NODE_ENV }, "predictify-backend listening"); diff --git a/src/services/indexerService.ts b/src/services/indexerService.ts index d3744e10..d7217e39 100644 --- a/src/services/indexerService.ts +++ b/src/services/indexerService.ts @@ -2,7 +2,7 @@ import { rpc } from "@stellar/stellar-sdk"; import { env } from "../config/env"; import { logger } from "../config/logger"; import { getPool } from "../db/client"; -import { detectReorgs, dedupeEvents, type RecoveryEvent } from "./indexerRecovery"; +import { dedupeEvents, type RecoveryEvent } from "./indexerRecovery"; export const INDEXER_CURSOR_ID = 1; diff --git a/src/services/referralService.ts b/src/services/referralService.ts index b276f82b..8a51277e 100644 --- a/src/services/referralService.ts +++ b/src/services/referralService.ts @@ -7,8 +7,7 @@ * route layer and tests can treat them as injectable dependencies. */ -import { eq, and, desc } from "drizzle-orm"; -import { v4 as uuidv4 } from "uuid"; +import { eq, desc } from "drizzle-orm"; import { db } from "../db/client"; import { referrals, type Referral, type NewReferral } from "../db/schema"; diff --git a/src/services/userService.ts b/src/services/userService.ts index b4cab767..3b4a6906 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -1,5 +1,5 @@ import { db } from "../db/client"; -import { users, predictions, markets, claims } from "../db/schema"; +import { users, predictions, markets } from "../db/schema"; import { and, eq, desc, lt, count, or } from "drizzle-orm"; import { Result, ok, err } from "../errors/RouteError"; import { encodeCursor, decodeCursor, clampLimit, DEFAULT_PAGE_SIZE } from "../utils/cursor"; diff --git a/src/workers/predictionsConfirmer.ts b/src/workers/predictionsConfirmer.ts index 63ef2259..619f5c2a 100644 --- a/src/workers/predictionsConfirmer.ts +++ b/src/workers/predictionsConfirmer.ts @@ -62,8 +62,8 @@ export type WebhookEmitter = ( /** Default emitter that delegates to the production webhook dispatcher. */ export const defaultWebhookEmitter: WebhookEmitter = async ( - eventType: string, - payload: Record, + _eventType: string, + _payload: Record, ): Promise => { // We need a db handle; in production the service creates one internally. // This function signature matches what dispatchEvent expects minus the db arg. diff --git a/tests/openapi.drift.test.ts b/tests/openapi.drift.test.ts new file mode 100644 index 00000000..3aa0f44e --- /dev/null +++ b/tests/openapi.drift.test.ts @@ -0,0 +1,193 @@ +import * as fs from "fs"; +import * as path from "path"; +import { + validateStructure, + validateRouteCoverage, + validateArtifactDrift, + validateRouteInvariants, + checkOpenApi, + normalizeLineEndings, + EXPECTED_ROUTES, + RouteEntry, +} from "../scripts/check-openapi"; +import { getOpenApiSpec, resetOpenApiCache } from "../src/openapi/builder"; + +describe("OpenAPI Drift and Contract Invariant Checks", () => { + let spec: ReturnType; + + beforeAll(() => { + resetOpenApiCache(); + spec = getOpenApiSpec(); + }); + + describe("normalizeLineEndings", () => { + it("normalizes CRLF to LF", () => { + const input = "line1\r\nline2\r\n"; + expect(normalizeLineEndings(input)).toBe("line1\nline2"); + }); + }); + + describe("validateStructure", () => { + it("passes for the generated specification", () => { + const result = validateStructure(spec); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("rejects non-object or null specs", () => { + expect(validateStructure(null).valid).toBe(false); + expect(validateStructure(undefined).valid).toBe(false); + expect(validateStructure("string").valid).toBe(false); + }); + + it("rejects invalid or missing OpenAPI version", () => { + const badVersion = { ...spec, openapi: "2.0" }; + const res = validateStructure(badVersion); + expect(res.valid).toBe(false); + expect(res.errors.some((e) => e.includes("openapi version"))).toBe(true); + }); + + it("rejects missing info section or title/version", () => { + const noInfo = { ...spec, info: null }; + expect(validateStructure(noInfo).valid).toBe(false); + + const noTitle = { ...spec, info: { version: "1.0.0" } }; + expect(validateStructure(noTitle).valid).toBe(false); + }); + + it("rejects empty or missing paths", () => { + const noPaths = { ...spec, paths: {} }; + const res = validateStructure(noPaths); + expect(res.valid).toBe(false); + expect(res.errors.some((e) => e.includes("no paths"))).toBe(true); + }); + }); + + describe("validateRouteCoverage", () => { + it("passes when spec matches expected routes exactly", () => { + const res = validateRouteCoverage(spec, EXPECTED_ROUTES); + expect(res.valid).toBe(true); + expect(res.errors).toHaveLength(0); + }); + + it("detects missing routes from the spec", () => { + const extendedExpected: RouteEntry[] = [ + ...EXPECTED_ROUTES, + { method: "get", path: "/api/missing/route" }, + ]; + const res = validateRouteCoverage(spec, extendedExpected); + expect(res.valid).toBe(false); + expect(res.errors.some((e) => e.includes("MISSING") && e.includes("/api/missing/route"))).toBe(true); + }); + + it("detects extra undocumented routes in the spec", () => { + const reducedExpected = EXPECTED_ROUTES.slice(1); + const res = validateRouteCoverage(spec, reducedExpected); + expect(res.valid).toBe(false); + expect(res.errors.some((e) => e.includes("EXTRA") && e.includes(EXPECTED_ROUTES[0].path))).toBe(true); + }); + }); + + describe("validateArtifactDrift", () => { + it("passes when checked-in openapi.yaml matches generated spec", () => { + const res = validateArtifactDrift(spec); + expect(res.valid).toBe(true); + expect(res.errors).toHaveLength(0); + }); + + it("fails when artifact file does not exist", () => { + const nonExistentPath = path.resolve(__dirname, "../nonexistent-openapi.yaml"); + const res = validateArtifactDrift(spec, nonExistentPath); + expect(res.valid).toBe(false); + expect(res.errors.some((e) => e.includes("not found"))).toBe(true); + }); + + it("fails when artifact file has stale content", () => { + const tempPath = path.resolve(__dirname, "../scratch-stale-openapi.yaml"); + fs.writeFileSync(tempPath, "openapi: 3.1.0\ninfo:\n title: Stale API\n", "utf-8"); + try { + const res = validateArtifactDrift(spec, tempPath); + expect(res.valid).toBe(false); + expect(res.errors.some((e) => e.includes("stale"))).toBe(true); + } finally { + if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); + } + }); + }); + + describe("validateRouteInvariants", () => { + it("passes for all operations in the active OpenAPI specification", () => { + const res = validateRouteInvariants(spec); + expect(res.valid).toBe(true); + expect(res.errors).toHaveLength(0); + }); + + it("fails if an operation is missing an operationId", () => { + const mutatedSpec = JSON.parse(JSON.stringify(spec)); + delete mutatedSpec.paths["/health"].get.operationId; + const res = validateRouteInvariants(mutatedSpec); + expect(res.valid).toBe(false); + expect(res.errors.some((e) => e.includes("operationId"))).toBe(true); + }); + + it("fails if duplicate operationIds are used", () => { + const mutatedSpec = JSON.parse(JSON.stringify(spec)); + mutatedSpec.paths["/health"].get.operationId = "duplicateOpId"; + mutatedSpec.paths["/metrics"].get.operationId = "duplicateOpId"; + const res = validateRouteInvariants(mutatedSpec); + expect(res.valid).toBe(false); + expect(res.errors.some((e) => e.includes("duplicate operationId"))).toBe(true); + }); + + it("fails if an operation is missing tags", () => { + const mutatedSpec = JSON.parse(JSON.stringify(spec)); + mutatedSpec.paths["/health"].get.tags = []; + const res = validateRouteInvariants(mutatedSpec); + expect(res.valid).toBe(false); + expect(res.errors.some((e) => e.includes("missing tags"))).toBe(true); + }); + + it("fails if an operation is missing summary and description", () => { + const mutatedSpec = JSON.parse(JSON.stringify(spec)); + delete mutatedSpec.paths["/health"].get.summary; + delete mutatedSpec.paths["/health"].get.description; + const res = validateRouteInvariants(mutatedSpec); + expect(res.valid).toBe(false); + expect(res.errors.some((e) => e.includes("missing summary"))).toBe(true); + }); + + it("fails if a protected route lacks 401/403 responses", () => { + const mutatedSpec = JSON.parse(JSON.stringify(spec)); + const authOp = mutatedSpec.paths["/api/users/me"].get; + delete authOp.responses["401"]; + delete authOp.responses["403"]; + const res = validateRouteInvariants(mutatedSpec); + expect(res.valid).toBe(false); + expect(res.errors.some((e) => e.includes("protected route must document an auth error"))).toBe(true); + }); + + it("fails if URL path parameters are not declared in parameters list", () => { + const mutatedSpec = JSON.parse(JSON.stringify(spec)); + mutatedSpec.paths["/api/markets/{id}"].get.parameters = []; + const res = validateRouteInvariants(mutatedSpec); + expect(res.valid).toBe(false); + expect(res.errors.some((e) => e.includes("path parameter '{id}'"))).toBe(true); + }); + + it("fails if a paginated route does not document a 400 validation error response", () => { + const mutatedSpec = JSON.parse(JSON.stringify(spec)); + delete mutatedSpec.paths["/api/users"].get.responses["400"]; + const res = validateRouteInvariants(mutatedSpec); + expect(res.valid).toBe(false); + expect(res.errors.some((e) => e.includes("paginated endpoint must document a 400"))).toBe(true); + }); + }); + + describe("checkOpenApi end-to-end", () => { + it("returns success: true with zero errors on valid repository state", () => { + const res = checkOpenApi(spec); + expect(res.success).toBe(true); + expect(res.errors).toHaveLength(0); + }); + }); +});