From e8df66ae6135577444ffe0494eb9a558af8cd0aa Mon Sep 17 00:00:00 2001 From: Komelove Date: Tue, 30 Jun 2026 02:35:52 +0100 Subject: [PATCH] feat: admin-only POST /api/markets create endpoint --- src/routes/markets/index.ts | 69 +++++++++++++++++++++++++- src/services/marketService.ts | 92 +++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/src/routes/markets/index.ts b/src/routes/markets/index.ts index 2ac54a9..f1c1939 100644 --- a/src/routes/markets/index.ts +++ b/src/routes/markets/index.ts @@ -2,7 +2,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Router } from "express"; -import { listMarkets, listUpcomingMarkets, getMarketById, updateMarket, VersionConflictError } from "../services/marketService"; +import { listMarkets, listUpcomingMarkets, getMarketById, updateMarket, VersionConflictError, createMarket, MarketExistsError } from "../services/marketService"; import { searchMarkets } from "../repositories/marketRepository"; import { requireAdmin, AuthenticatedRequest } from "../middleware/auth"; import { rateLimitAnon } from "../middleware/rateLimitAnon"; @@ -28,6 +28,73 @@ const patchMarketSchema = z.object({ expectedVersion: z.number().int().nonnegative(), }).strict(); +/** + * Zod schema for POST /api/markets. + * + * - id: on-chain contract id (non-empty, max 128 chars) + * - question: canonical prediction question (1–500 chars) + * - resolutionTime: ISO 8601 datetime string, must be in the future + * - metadata: optional free-form object (validated for size in service layer) + */ +const createMarketSchema = z.object({ + id: z.string().min(1).max(128), + question: z.string().min(1).max(500), + resolutionTime: z.string().datetime({ message: "resolutionTime must be an ISO 8601 datetime" }), + metadata: z.record(z.unknown()).optional(), +}).strict(); + +/** + * POST /api/markets — create an off-chain market shell (admin only). + * + * The caller must supply the on-chain contract id generated by the Soroban + * deployer. The row is inserted with status="pending", indexedLedger=0, and + * archived=false so the indexer can hydrate it once the contract is live. + * + * Returns 409 `market_exists` on duplicate id. + * Returns 403 `forbidden` for non-admin callers. + */ +marketsRouter.post("/", requireAdmin, async (req: AuthenticatedRequest, res, next) => { + const reqId = String((req as any).id ?? "anon"); + const adminAddress = req.user?.stellarAddress; + + try { + const parsed = createMarketSchema.safeParse(req.body); + if (!parsed.success) { + logger.warn({ reqId, adminAddress, issues: parsed.error.issues }, "markets_create_validation_failed"); + return res.status(400).json({ + error: { + code: "validation_error", + message: "Invalid request body", + details: parsed.error.issues, + correlationId: reqId, + }, + }); + } + + const { id, question, resolutionTime, metadata } = parsed.data; + + logger.info({ reqId, adminAddress, marketId: id }, "markets_create_start"); + + const market = await createMarket( + { id, question, resolutionTime: new Date(resolutionTime), metadata }, + adminAddress!, + ); + + logger.info({ reqId, adminAddress, marketId: id }, "markets_create_success"); + return res.status(201).json({ data: market }); + } catch (e: any) { + if (e instanceof MarketExistsError) { + logger.warn({ reqId, adminAddress, marketId: req.body?.id }, "markets_create_duplicate"); + return res.status(409).json({ error: { code: "market_exists", correlationId: reqId } }); + } + if (e?.status === 400) { + return res.status(400).json({ error: { code: "validation_error", message: e.message, correlationId: reqId } }); + } + logger.error({ reqId, adminAddress, err: e }, "markets_create_failed"); + return next(e); + } +}); + marketsRouter.get("/search", async (req, res, next) => { const reqId = String((req as any).id ?? "anon"); try { diff --git a/src/services/marketService.ts b/src/services/marketService.ts index d4553dd..0e036af 100644 --- a/src/services/marketService.ts +++ b/src/services/marketService.ts @@ -4,6 +4,98 @@ import { markets, marketAuditLog } from "../db/schema"; import { and, asc, eq, gt, inArray } from "drizzle-orm"; import { emitMarketEvent, LogEvent } from "../logging/events"; +/** Max character lengths enforced before hitting the DB */ +const QUESTION_MAX_LEN = 500; +const METADATA_MAX_JSON_LEN = 4096; + +export class MarketExistsError extends Error { + status = 409; + code = "market_exists"; + constructor() { + super("Market already exists"); + Object.setPrototypeOf(this, MarketExistsError.prototype); + } +} + +/** Zod-validated shape coming in from the route layer */ +export interface CreateMarketInput { + id: string; + question: string; + resolutionTime: Date; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + metadata?: any; +} + +/** + * Creates an off-chain market shell keyed by the on-chain contract id. + * + * Idempotent on `id`: throws `MarketExistsError` (409) on duplicate. + * The row is inserted with `status = "pending"`, `indexedLedger = 0`, + * and `archived = false` so the indexer can later hydrate it. + * + * @param input - Validated market creation payload + * @param adminAddress - Stellar address of the admin creating the market + * @returns The persisted market row + * @throws MarketExistsError if a market with the same id already exists + */ +export async function createMarket( + input: CreateMarketInput, + adminAddress: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise { + const { id, question, resolutionTime, metadata } = input; + + // Guard: validate lengths before hitting Postgres + if (question.length > QUESTION_MAX_LEN) { + const err = new Error(`question must be at most ${QUESTION_MAX_LEN} characters`); + (err as any).status = 400; + throw err; + } + + if (metadata !== undefined) { + const serialized = JSON.stringify(metadata); + if (serialized.length > METADATA_MAX_JSON_LEN) { + const err = new Error(`metadata JSON must be at most ${METADATA_MAX_JSON_LEN} characters`); + (err as any).status = 400; + throw err; + } + } + + // Check for duplicate before insert to return a clear 409 + const existing = await getDb() + .select({ id: markets.id }) + .from(markets) + .where(eq(markets.id, id)) + .limit(1); + + if (existing.length > 0) { + throw new MarketExistsError(); + } + + const [row] = await getDb() + .insert(markets) + .values({ + id, + question, + resolutionTime, + metadata: metadata ?? null, + status: "pending", + indexedLedger: 0, + archived: false, + version: 1, + }) + .returning(); + + emitMarketEvent(LogEvent.MARKET_UPDATED, { + marketId: id, + actor: adminAddress, + version: row.version, + fieldsUpdated: ["id", "question", "resolutionTime", "metadata"], + }); + + return row; +} + export interface Market { id: string; question: string;