Skip to content
Open
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
81 changes: 81 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
48 changes: 36 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand All @@ -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])
Expand All @@ -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;
Expand All @@ -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) => {
Expand All @@ -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();
Expand Down
25 changes: 25 additions & 0 deletions src/openapi/builder.ts
Original file line number Diff line number Diff line change
@@ -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<OpenApiGeneratorV3["generateDocument"]> | 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;
}
Loading