From 36e1d0e6cec4db7d7391772b1d60da410df8803d Mon Sep 17 00:00:00 2001 From: shaarknado Date: Sat, 27 Jun 2026 16:14:05 +0000 Subject: [PATCH] feat: OpenAPI spec generation and Swagger UI - Add src/openapi/registry.ts: all public route schemas via zod-to-openapi - Add src/openapi/builder.ts: generates + caches OpenAPI 3.0 document - GET /openapi.json always available; /docs gated by NODE_ENV != production || ENABLE_DOCS=true - Update src/routes/docs.ts to serve generated spec via swagger-ui-express - Fix pre-existing broken imports in auth.ts, disputes.ts, predictions.ts, leaderboard - Add refresh_tokens table to schema - Add tests/openapi.test.ts: 9 tests covering shape, paths, schemas, snapshot - Update README with /docs and /openapi.json Quick start links --- README.md | 5 + package-lock.json | 81 ++ package.json | 1 + src/db/schema.ts | 15 + src/index.ts | 48 +- src/openapi/builder.ts | 25 + src/openapi/registry.ts | 342 +++++++ src/routes/auth.ts | 59 +- src/routes/disputes.ts | 31 +- src/routes/docs.ts | 54 +- src/routes/leaderboard.ts | 3 +- src/routes/predictions.ts | 3 +- src/services/leaderboardService.ts | 2 +- tests/__snapshots__/openapi.test.ts.snap | 1158 ++++++++++++++++++++++ tests/openapi.test.ts | 76 ++ 15 files changed, 1816 insertions(+), 87 deletions(-) create mode 100644 src/openapi/builder.ts create mode 100644 src/openapi/registry.ts create mode 100644 tests/__snapshots__/openapi.test.ts.snap create mode 100644 tests/openapi.test.ts diff --git a/README.md b/README.md index 2772b0e..ee55bbb 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,11 @@ npm run db:migrate npm run dev # predev hook re-runs check-env automatically ``` +Once running: + +- **Swagger UI** → http://localhost:3000/docs *(non-production only; set `ENABLE_DOCS=true` to enable in production)* +- **OpenAPI JSON** → http://localhost:3000/openapi.json *(always available)* + ## Indexer gap scan The gap-scan worker detects missing ledger ranges in `indexer_events` between the durable cursor and chain tip, emits `indexer_gap_detected_total{from,to}`, and self-heals via `backfillRange`: diff --git a/package-lock.json b/package-lock.json index 952e01a..f39de0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "MIT", "dependencies": { + "@asteasolutions/zod-to-openapi": "7.3.4", "@stellar/stellar-sdk": "^12.0.0", "drizzle-orm": "^0.45.2", "express": "^4.21.0", @@ -19,6 +20,7 @@ "pino": "^9.4.0", "pino-http": "^10.3.0", "prom-client": "^15.1.3", + "swagger-ui-express": "^5.0.1", "uuid": "^10.0.0", "zod": "^3.23.8" }, @@ -30,6 +32,7 @@ "@types/node": "^22.7.5", "@types/pg": "^8.11.10", "@types/supertest": "^6.0.2", + "@types/swagger-ui-express": "^4.1.7", "@types/uuid": "^10.0.0", "drizzle-kit": "^0.31.10", "eslint": "^9.12.0", @@ -44,6 +47,18 @@ "node": ">=20" } }, + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-7.3.4.tgz", + "integrity": "sha512-/2rThQ5zPi9OzVwes6U7lK1+Yvug0iXu25olp7S0XsYmOqnyMfxH7gdSQjn/+DSOHRg7wnotwGJSyL+fBKdnEA==", + "license": "MIT", + "dependencies": { + "openapi3-ts": "^4.1.2" + }, + "peerDependencies": { + "zod": "^3.20.2" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -2156,6 +2171,13 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -2544,6 +2566,17 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -6695,6 +6728,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi3-ts": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.6.0.tgz", + "integrity": "sha512-a4sfn6L2sIShhtzJqmjGrARvxAW/3F2BJDdyRVvNF9VhAsZSh5hSyI3a9TNvmzBxXmq66nY5LNT5bQcBxYAZZg==", + "license": "MIT", + "dependencies": { + "yaml": "^2.9.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7959,6 +8001,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.32.8", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz", + "integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/tdigest": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", @@ -9173,6 +9239,21 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", diff --git a/package.json b/package.json index 96558b6..5a76a03 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "db:check-drift": "ts-node scripts/check-drizzle-drift.ts" }, "dependencies": { + "@asteasolutions/zod-to-openapi": "7.3.4", "@stellar/stellar-sdk": "^12.0.0", "drizzle-orm": "^0.45.2", "express": "^4.21.0", diff --git a/src/db/schema.ts b/src/db/schema.ts index 2f28734..9e71492 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -14,6 +14,21 @@ export const authChallenges = pgTable("auth_challenges", { createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); +// --------------------------------------------------------------------------- +// Refresh token table +// --------------------------------------------------------------------------- + +export const refreshTokens = pgTable("refresh_tokens", { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id").notNull().references(() => users.id), + tokenHash: text("token_hash").notNull().unique(), + familyId: uuid("family_id").notNull(), + parentId: uuid("parent_id"), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + // --------------------------------------------------------------------------- // Webhook tables // --------------------------------------------------------------------------- diff --git a/src/index.ts b/src/index.ts index d67b94d..d6d579d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,22 +3,43 @@ import helmet from "helmet"; import pinoHttp from "pino-http"; import { env } from "./config/env"; import { logger } from "./config/logger"; +import { metricsMiddleware } from "./metrics/httpMetrics"; +import { idempotency } from "./middleware/idempotency"; import { healthRouter } from "./routes/health"; +import { authRouter } from "./routes/auth"; import { marketsRouter } from "./routes/markets"; import { usersRouter } from "./routes/users"; +import { predictionsRouter } from "./routes/predictions"; +import { leaderboardRouter } from "./routes/leaderboard"; +import { disputesRouter } from "./routes/disputes"; +import { marketEventsRouter } from "./routes/marketEvents"; +import { adminUsersRouter } from "./routes/adminUsers"; +import { reconciliationRouter } from "./routes/reconciliation"; import { createDocsRouter } from "./routes/docs"; +import { getOpenApiSpec } from "./openapi/builder"; import { errorHandler } from "./middleware/errorHandler"; import { connectWithRetry, closeDb } from "./db/client"; +import { stopScheduler } from "./services/scheduler"; + +const docsEnabled = + env.NODE_ENV !== "production" || process.env.ENABLE_DOCS === "true"; export function createApp(): express.Express { const app = express(); - // ── Swagger UI docs (scoped relaxed CSP) ────────────────────────────── + // ── OpenAPI JSON spec (always available) ────────────────────────────── + app.get("/openapi.json", (_req, res) => { + res.json(getOpenApiSpec()); + }); + + // ── Swagger UI (non-production or ENABLE_DOCS=true) ─────────────────── // Must be mounted BEFORE the global helmet() so /docs receives its own // relaxed Content-Security-Policy. See docs/security.md. - app.use("/docs", createDocsRouter()); + if (docsEnabled) { + app.use("/docs", createDocsRouter()); + } - // ── Global strict CSP (everything except /docs) ─────────────────────── + // ── Global strict CSP ───────────────────────────────────────────────── app.use(helmet()); app.use(express.json({ limit: "256kb" })); app.use(pinoHttp({ logger })); @@ -27,7 +48,6 @@ export function createApp(): express.Express { app.use("/health", healthRouter); // Idempotency guard for all state-mutating routes under /api. - // Must be mounted before the routers it protects. const mutationMethods = ["POST", "PATCH"] as const; app.use("/api", (req, res, next) => mutationMethods.includes(req.method as (typeof mutationMethods)[number]) @@ -37,7 +57,14 @@ export function createApp(): express.Express { app.use("/api/auth", authRouter); app.use("/api/markets", marketsRouter); + // Disputes are nested under markets: POST /api/markets/:id/disputes + app.use("/api/markets/:id", disputesRouter); + app.use("/api/markets", marketEventsRouter); app.use("/api/users", usersRouter); + app.use("/api/predictions", predictionsRouter); + app.use("/api/leaderboard", leaderboardRouter); + app.use("/api/admin/users", adminUsersRouter); + app.use("/api/reconciliation", reconciliationRouter); app.use(errorHandler); return app; @@ -50,6 +77,9 @@ if (require.main === module) { .then(() => { app.listen(env.PORT, () => { logger.info({ port: env.PORT, env: env.NODE_ENV }, "predictify-backend listening"); + if (docsEnabled) { + logger.info(`Swagger UI available at http://localhost:${env.PORT}/docs`); + } }); }) .catch((err) => { @@ -64,18 +94,12 @@ if (require.main === module) { process.exit(1); }, 5000).unref(); + stopScheduler(); await closeDb(); clearTimeout(forceExit); process.exit(0); }); - - // Graceful shutdown - process.on("SIGTERM", () => { - logger.info("SIGTERM received, shutting down gracefully"); - stopScheduler(); - process.exit(0); - }); - + process.on("SIGINT", () => { logger.info("SIGINT received, shutting down gracefully"); stopScheduler(); diff --git a/src/openapi/builder.ts b/src/openapi/builder.ts new file mode 100644 index 0000000..3658c2a --- /dev/null +++ b/src/openapi/builder.ts @@ -0,0 +1,25 @@ +/** + * Builds and caches the OpenAPI 3.0 document from the central registry. + * Import `getOpenApiSpec` wherever you need the generated spec object. + */ + +import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi"; +import { registry } from "./registry"; + +let _cached: ReturnType | null = null; + +export function getOpenApiSpec() { + if (_cached) return _cached; + + _cached = new OpenApiGeneratorV3(registry.definitions).generateDocument({ + openapi: "3.0.3", + info: { + title: "Predictify API", + version: "0.0.1", + description: "Backend API for Predictify — a Stellar/Soroban prediction-markets dApp", + }, + servers: [{ url: "/" }], + }); + + return _cached; +} diff --git a/src/openapi/registry.ts b/src/openapi/registry.ts new file mode 100644 index 0000000..75f398e --- /dev/null +++ b/src/openapi/registry.ts @@ -0,0 +1,342 @@ +/** + * Central OpenAPI registry. + * + * All public-route schemas are registered here so the spec is generated + * entirely from Zod definitions — never hand-edited. + */ + +import { z } from "zod"; +import { extendZodWithOpenApi, OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; + +extendZodWithOpenApi(z); + +export const registry = new OpenAPIRegistry(); + +// ── Reusable component schemas ─────────────────────────────────────────────── + +const ErrorBody = registry.register( + "ErrorBody", + z.object({ error: z.object({ code: z.string() }) }).openapi("ErrorBody"), +); + +const ValidationErrorBody = registry.register( + "ValidationErrorBody", + z + .object({ error: z.object({ code: z.string(), details: z.any().optional() }) }) + .openapi("ValidationErrorBody"), +); + +// ── Bearerauth security scheme ─────────────────────────────────────────────── + +registry.registerComponent("securitySchemes", "bearerAuth", { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", +}); + +// ── /health ────────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/health", + tags: ["Health"], + summary: "Liveness check", + responses: { + 200: { + description: "Service is healthy", + content: { "application/json": { schema: z.object({ status: z.literal("ok") }) } }, + }, + }, +}); + +// ── /api/auth ──────────────────────────────────────────────────────────────── + +const ChallengeRequest = z.object({ stellarAddress: z.string() }).openapi("ChallengeRequest"); +const ChallengeResponse = z + .object({ nonce: z.string(), expiresAt: z.string().datetime() }) + .openapi("ChallengeResponse"); + +registry.registerPath({ + method: "post", + path: "/api/auth/challenge", + tags: ["Auth"], + summary: "Request a sign-in challenge nonce", + request: { body: { content: { "application/json": { schema: ChallengeRequest } } } }, + responses: { + 201: { description: "Challenge issued", content: { "application/json": { schema: ChallengeResponse } } }, + 400: { description: "Validation error", content: { "application/json": { schema: ValidationErrorBody } } }, + }, +}); + +const VerifyRequest = z + .object({ stellarAddress: z.string(), nonce: z.string(), signature: z.string() }) + .openapi("VerifyRequest"); +const TokenPair = z + .object({ accessToken: z.string(), refreshToken: z.string() }) + .openapi("TokenPair"); + +registry.registerPath({ + method: "post", + path: "/api/auth/verify", + tags: ["Auth"], + summary: "Verify challenge signature and obtain JWT", + request: { body: { content: { "application/json": { schema: VerifyRequest } } } }, + responses: { + 200: { description: "Tokens issued", content: { "application/json": { schema: TokenPair } } }, + 400: { description: "Validation error", content: { "application/json": { schema: ValidationErrorBody } } }, + 401: { description: "Invalid signature", content: { "application/json": { schema: ErrorBody } } }, + }, +}); + +const RefreshRequest = z.object({ refreshToken: z.string().min(1) }).openapi("RefreshRequest"); + +registry.registerPath({ + method: "post", + path: "/api/auth/refresh", + tags: ["Auth"], + summary: "Rotate a refresh token", + request: { body: { content: { "application/json": { schema: RefreshRequest } } } }, + responses: { + 200: { description: "New token pair", content: { "application/json": { schema: TokenPair } } }, + 400: { description: "Missing token", content: { "application/json": { schema: ErrorBody } } }, + 401: { description: "Invalid token", content: { "application/json": { schema: ErrorBody } } }, + 403: { description: "Reuse detected — family revoked", content: { "application/json": { schema: ErrorBody } } }, + }, +}); + +registry.registerPath({ + method: "post", + path: "/api/auth/logout", + tags: ["Auth"], + summary: "Revoke the entire refresh-token family", + request: { body: { content: { "application/json": { schema: RefreshRequest } } } }, + responses: { + 204: { description: "Logged out" }, + 400: { description: "Missing token", content: { "application/json": { schema: ErrorBody } } }, + }, +}); + +// ── /api/markets ───────────────────────────────────────────────────────────── + +const Market = z + .object({ + id: z.string().uuid(), + question: z.string(), + status: z.string(), + metadata: z.any().optional(), + version: z.number().int(), + createdAt: z.string().datetime(), + }) + .openapi("Market"); + +registry.registerPath({ + method: "get", + path: "/api/markets", + tags: ["Markets"], + summary: "List all markets", + responses: { + 200: { + description: "Array of markets", + content: { "application/json": { schema: z.object({ data: z.array(Market) }) } }, + }, + }, +}); + +registry.registerPath({ + method: "get", + path: "/api/markets/{id}", + tags: ["Markets"], + summary: "Get a market by ID", + request: { params: z.object({ id: z.string().uuid() }) }, + responses: { + 200: { description: "Market", content: { "application/json": { schema: z.object({ data: Market }) } } }, + 404: { description: "Not found", content: { "application/json": { schema: ErrorBody } } }, + }, +}); + +const PatchMarketRequest = z + .object({ + question: z.string().optional(), + metadata: z.any().optional(), + expectedVersion: z.number().int().nonnegative(), + }) + .openapi("PatchMarketRequest"); + +registry.registerPath({ + method: "patch", + path: "/api/markets/{id}", + tags: ["Markets"], + summary: "Update a market (admin only)", + security: [{ bearerAuth: [] }], + request: { + params: z.object({ id: z.string().uuid() }), + body: { content: { "application/json": { schema: PatchMarketRequest } } }, + }, + responses: { + 200: { description: "Updated market", content: { "application/json": { schema: z.object({ data: Market }) } } }, + 400: { description: "Validation error", content: { "application/json": { schema: ValidationErrorBody } } }, + 404: { description: "Not found", content: { "application/json": { schema: ErrorBody } } }, + 409: { description: "Version conflict", content: { "application/json": { schema: ErrorBody } } }, + }, +}); + +// ── /api/markets/{id}/disputes ─────────────────────────────────────────────── + +const OpenDisputeRequest = z + .object({ reason: z.string().min(10).max(500), evidenceUri: z.string().url().nullable().optional() }) + .openapi("OpenDisputeRequest"); + +const Dispute = z + .object({ id: z.string().uuid(), marketId: z.string(), reason: z.string(), status: z.string() }) + .openapi("Dispute"); + +registry.registerPath({ + method: "post", + path: "/api/markets/{id}/disputes", + tags: ["Disputes"], + summary: "Open a dispute on a market", + security: [{ bearerAuth: [] }], + request: { + params: z.object({ id: z.string().uuid() }), + body: { content: { "application/json": { schema: OpenDisputeRequest } } }, + }, + responses: { + 201: { description: "Dispute created", content: { "application/json": { schema: z.object({ data: Dispute }) } } }, + 400: { description: "Validation error", content: { "application/json": { schema: ValidationErrorBody } } }, + 401: { description: "Unauthorized", content: { "application/json": { schema: ErrorBody } } }, + }, +}); + +// ── /api/markets/{id}/events ───────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/markets/{id}/events", + tags: ["Markets"], + summary: "SSE stream of market events", + request: { params: z.object({ id: z.string().uuid() }) }, + responses: { + 200: { description: "Server-Sent Events stream", content: { "text/event-stream": { schema: z.string() } } }, + 400: { description: "Bad request", content: { "application/json": { schema: ErrorBody } } }, + }, +}); + +// ── /api/users ─────────────────────────────────────────────────────────────── + +const PredictionStatus = z.enum(["pending", "confirmed", "won", "lost", "claimed"]); + +const Prediction = z + .object({ id: z.string().uuid(), marketId: z.string(), status: PredictionStatus, createdAt: z.string().datetime() }) + .openapi("Prediction"); + +registry.registerPath({ + method: "get", + path: "/api/users/{address}/predictions", + tags: ["Users"], + summary: "List predictions for a Stellar address", + request: { + params: z.object({ address: z.string() }), + query: z.object({ + status: PredictionStatus.optional(), + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), + }), + }, + responses: { + 200: { + description: "Paginated predictions", + content: { + "application/json": { + schema: z.object({ data: z.array(Prediction), nextCursor: z.string().nullable() }), + }, + }, + }, + 400: { description: "Invalid address", content: { "application/json": { schema: ErrorBody } } }, + 404: { description: "User not found", content: { "application/json": { schema: ErrorBody } } }, + }, +}); + +// ── /api/predictions ───────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/predictions", + tags: ["Predictions"], + summary: "Get predictions for the authenticated user", + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: "Predictions list", + content: { + "application/json": { schema: z.object({ data: z.array(Prediction), user: z.any() }) }, + }, + }, + 401: { description: "Unauthorized", content: { "application/json": { schema: ErrorBody } } }, + }, +}); + +// ── /api/leaderboard ───────────────────────────────────────────────────────── + +const LeaderboardEntry = z + .object({ rank: z.number().int(), stellarAddress: z.string(), score: z.number() }) + .openapi("LeaderboardEntry"); + +registry.registerPath({ + method: "get", + path: "/api/leaderboard", + tags: ["Leaderboard"], + summary: "Get global leaderboard", + request: { + query: z.object({ + limit: z.coerce.number().int().positive().max(100).default(50), + offset: z.coerce.number().int().nonnegative().default(0), + refresh: z.coerce.boolean().default(false), + }), + }, + responses: { + 200: { + description: "Leaderboard entries", + content: { + "application/json": { + schema: z.object({ + data: z.array(LeaderboardEntry), + meta: z.object({ limit: z.number(), offset: z.number(), count: z.number(), refresh: z.boolean() }), + }), + }, + }, + }, + }, +}); + +registry.registerPath({ + method: "get", + path: "/api/leaderboard/user/{stellarAddress}", + tags: ["Leaderboard"], + summary: "Get leaderboard entry for a specific user", + request: { params: z.object({ stellarAddress: z.string() }) }, + responses: { + 200: { description: "Entry", content: { "application/json": { schema: z.object({ data: LeaderboardEntry }) } } }, + 404: { description: "Not found", content: { "application/json": { schema: ErrorBody } } }, + }, +}); + +// ── /api/admin/users ───────────────────────────────────────────────────────── + +const AdminUserView = z + .object({ address: z.string(), predictions: z.array(Prediction), disputes: z.array(Dispute) }) + .openapi("AdminUserView"); + +registry.registerPath({ + method: "get", + path: "/api/admin/users/{address}", + tags: ["Admin"], + summary: "Get aggregated user view (admin only)", + security: [{ bearerAuth: [] }], + request: { params: z.object({ address: z.string() }) }, + responses: { + 200: { description: "User view", content: { "application/json": { schema: z.object({ data: AdminUserView }) } } }, + 401: { description: "Unauthorized", content: { "application/json": { schema: ErrorBody } } }, + 403: { description: "Forbidden", content: { "application/json": { schema: ErrorBody } } }, + 429: { description: "Rate limit exceeded", content: { "application/json": { schema: ErrorBody } } }, + }, +}); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 3b9e22e..a533dcd 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,13 +1,17 @@ import { Router } from "express"; import { z } from "zod"; +import { StrKey } from "@stellar/stellar-sdk"; import { RefreshTokenError, rotateRefreshToken, revokeFamily, } from "../services/refreshTokenService"; +import { createChallenge } from "../services/authChallengeService"; +import { verifyChallengeAndIssueJwt, AuthVerifyError } from "../services/authVerifyService"; import { logger } from "../config/logger"; export const authRouter = Router(); + const refreshTokenBodySchema = z.object({ refreshToken: z.string().min(1), }); @@ -35,15 +39,11 @@ authRouter.post("/refresh", async (req, res, next) => { logger.warn({ code: err.code }, "token_refresh_failed"); if (err.code === "reuseDetected") { - res.status(403).json({ - error: { code: "token_reuse_detected" }, - }); + res.status(403).json({ error: { code: "token_reuse_detected" } }); return; } - res.status(401).json({ - error: { code: "invalid_token" }, - }); + res.status(401).json({ error: { code: "invalid_token" } }); return; } @@ -51,7 +51,7 @@ authRouter.post("/refresh", async (req, res, next) => { } }); -authRouter.post("/challenge", async (req, res, next) => { +authRouter.post("/logout", async (req, res, next) => { try { const refreshToken = parseRefreshToken(req.body); @@ -62,12 +62,39 @@ authRouter.post("/challenge", async (req, res, next) => { return; } + await revokeFamily(refreshToken); + res.status(204).send(); + } catch (err) { + if (err instanceof RefreshTokenError) { + res.status(401).json({ error: { code: "invalid_token" } }); + return; + } + next(err); + } +}); + +const challengeBodySchema = z.object({ + stellarAddress: z.string().min(1), +}); + +authRouter.post("/challenge", async (req, res, next) => { + try { + const parsed = challengeBodySchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + error: { code: "invalid_request", message: "stellarAddress is required" }, + }); + return; + } + const result = await createChallenge(parsed.data.stellarAddress); res.status(201).json({ nonce: result.nonce, expiresAt: result.expiresAt.toISOString(), }); - } catch (e) { next(e); } + } catch (e) { + next(e); + } }); const verifyBodySchema = z.object({ @@ -83,10 +110,10 @@ authRouter.post("/verify", async (req, res, next) => { try { const parsed = verifyBodySchema.safeParse(req.body); if (!parsed.success) { - const err = new Error("Invalid auth verify request") as Error & { status: number; code: string }; - err.status = 400; - err.code = "invalid_request"; - throw err; + res.status(400).json({ + error: { code: "invalid_request", details: parsed.error.issues }, + }); + return; } const result = await verifyChallengeAndIssueJwt( @@ -96,5 +123,11 @@ authRouter.post("/verify", async (req, res, next) => { ); res.status(200).json(result); - } catch (e) { next(e); } + } catch (e) { + if (e instanceof AuthVerifyError) { + res.status(e.status).json({ error: { code: e.code } }); + return; + } + next(e); + } }); diff --git a/src/routes/disputes.ts b/src/routes/disputes.ts index c27e240..fa702c1 100644 --- a/src/routes/disputes.ts +++ b/src/routes/disputes.ts @@ -1,6 +1,6 @@ import { Router } from "express"; import { z } from "zod"; -import { requireAuth } from "../middleware/auth"; +import { requireAuth } from "../middleware/requireAuth"; import { openDispute, DisputeError } from "../services/disputeService"; import { validateHttpsUrl, validateSsrf } from "../utils/url"; import { logger } from "../config/logger"; @@ -14,20 +14,23 @@ const openDisputeSchema = z.object({ disputesRouter.post("/", requireAuth, async (req, res, next) => { try { - const marketId = req.params.id as string; + // mergeParams: true means :id from the parent router is available here + const marketId = (req.params as Record).id; if (!marketId) { - return res.status(400).json({ error: { code: "bad_request", message: "Market ID is required" } }); + res.status(400).json({ error: { code: "bad_request", message: "Market ID is required" } }); + return; } const parsed = openDisputeSchema.safeParse(req.body); if (!parsed.success) { - return res.status(400).json({ + res.status(400).json({ error: { code: "validation_error", message: "Invalid request body", details: parsed.error.flatten().fieldErrors, }, }); + return; } const { reason, evidenceUri } = parsed.data; @@ -35,23 +38,24 @@ disputesRouter.post("/", requireAuth, async (req, res, next) => { if (evidenceUri) { const urlResult = validateHttpsUrl(evidenceUri); if (!urlResult.valid) { - return res.status(400).json({ - error: { code: "invalid_evidence_uri", message: urlResult.error }, - }); + res.status(400).json({ error: { code: "invalid_evidence_uri", message: urlResult.error } }); + return; } const ssrfResult = await validateSsrf(evidenceUri); if (!ssrfResult.valid) { logger.warn({ evidenceUri, error: ssrfResult.error }, "SSRF check failed for evidenceUri"); - return res.status(400).json({ - error: { code: "ssrf_check_failed", message: ssrfResult.error }, - }); + res.status(400).json({ error: { code: "ssrf_check_failed", message: ssrfResult.error } }); + return; } } + // req.user is guaranteed by requireAuth middleware + const userId = (req as unknown as { user: { id: string } }).user.id; + const dispute = await openDispute({ marketId, - userId: req.user!.sub, + userId, reason, evidenceUri: evidenceUri ?? null, }); @@ -59,8 +63,9 @@ disputesRouter.post("/", requireAuth, async (req, res, next) => { res.status(201).json({ data: dispute }); } catch (e) { if (e instanceof DisputeError) { - return res.status(e.status).json({ error: { code: e.code, message: e.message } }); + res.status(e.status).json({ error: { code: e.code, message: e.message } }); + return; } - return next(e); + next(e); } }); diff --git a/src/routes/docs.ts b/src/routes/docs.ts index 89c600b..6cd264f 100644 --- a/src/routes/docs.ts +++ b/src/routes/docs.ts @@ -1,58 +1,22 @@ import { Router } from "express"; import helmet from "helmet"; import swaggerUi from "swagger-ui-express"; +import { getOpenApiSpec } from "../openapi/builder"; /** - * Minimal OpenAPI 3.0 spec for Predictify. - * In production this would be generated from route schemas; - * kept inline here so the /docs page is self-contained. - */ -const swaggerDocument: Record = { - openapi: "3.0.3", - info: { - title: "Predictify API", - version: "0.0.1", - description: - "Backend API for Predictify — a Stellar/Soroban prediction-markets dApp", - }, - servers: [{ url: "/api" }], - paths: { - "/health": { - get: { - summary: "Health check", - responses: { "200": { description: "OK" } }, - }, - }, - "/markets": { - get: { - summary: "List markets", - responses: { "200": { description: "Array of markets" } }, - }, - }, - "/users": { - get: { - summary: "List users", - responses: { "200": { description: "Array of users" } }, - }, - }, - }, -}; - -/** - * Builds a router that serves Swagger UI at the mount point. + * Builds the /docs (Swagger UI) router. * - * A *scoped* helmet middleware is applied **only** to this router so that - * Swagger UI's inline scripts load without CSP violations, while the rest - * of the application retains the strict global CSP set in src/index.ts. + * A scoped helmet middleware is applied only to this router so that + * Swagger UI's inline scripts load without CSP violations, while the + * rest of the application retains the strict global CSP. * * See docs/security.md for the rationale behind this exception. + * + * Mount this router only when NODE_ENV !== 'production' or ENABLE_DOCS=true. */ export function createDocsRouter(): Router { const router = Router(); - // ── Scoped, relaxed CSP for Swagger UI only ────────────────────────── - // Swagger UI renders via inline