import express from "express"; import { getAccountByPublicKey, getZooByAccountId, getSalesForZoo, getActiveSaleListings, createSaleListing, placeBid, acceptSale, rejectSale, markSaleDelivered, expireSaleListings, processValidatedSales, } from "../db.js"; import { verifySignature, buildSignMessage, hashBody } from "../auth.js"; const router = express.Router(); const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; /** @param {string} timestamp * @returns {boolean} */ function isTimestampValid(timestamp) { const now = Date.now(); const ts = new Date(timestamp).getTime(); return !Number.isNaN(ts) && Math.abs(now - ts) <= TIMESTAMP_TOLERANCE_MS; } /** @param {import("express").Request} req * @returns {string} */ function getBodyForSignature(req) { return req.bodyRaw !== undefined && req.bodyRaw !== null ? req.bodyRaw : ""; } /** @param {import("express").Request} req * @param {string} publicKey * @param {string} signature * @param {string} timestamp * @returns {boolean} */ function isSignatureValid(req, publicKey, signature, timestamp) { const bodyHash = hashBody(getBodyForSignature(req)); const path = req.originalUrl || req.baseUrl + req.path || req.path; const message = buildSignMessage(req.method, path, timestamp, bodyHash); return verifySignature(publicKey, signature, message); } function requireSignature() { return (req, res, next) => { const publicKey = req.headers["x-public-key"]; const signature = req.headers["x-signature"]; const timestamp = req.headers["x-timestamp"]; if (!publicKey || !signature || !timestamp) { res.status(401).json({ error: "Missing X-Public-Key, X-Signature, or X-Timestamp" }); return; } if (!isTimestampValid(timestamp)) { res.status(401).json({ error: "Invalid or expired timestamp" }); return; } if (!isSignatureValid(req, publicKey, signature, timestamp)) { res.status(401).json({ error: "Invalid signature" }); return; } getAccountByPublicKey(publicKey).then((account) => { if (!account) { res.status(401).json({ error: "Unknown account" }); return; } req.account = account; next(); }).catch(next); }; } function optionalSignature() { return (req, res, next) => { const publicKey = req.headers["x-public-key"]; const signature = req.headers["x-signature"]; const timestamp = req.headers["x-timestamp"]; if (!publicKey || !signature || !timestamp) { next(); return; } if (!isTimestampValid(timestamp) || !isSignatureValid(req, publicKey, signature, timestamp)) { next(); return; } getAccountByPublicKey(publicKey).then((account) => { req.account = account ?? undefined; next(); }).catch(next); }; } /** GET /api/sales — optional auth: with auth returns asSeller, asBuyerUndelivered, active; without auth returns active only. * @param {import("express").Request} req * @returns {Promise<{ active: Array } | { asSeller: Array, asBuyerUndelivered: Array, active: Array }>} */ async function getSalesResponse(req) { await expireSaleListings(); if (!req.account) { const active = await getActiveSaleListings(); return { active }; } await processValidatedSales(); const zoo = await getZooByAccountId(req.account.id); if (!zoo) { return { asSeller: [], asBuyerUndelivered: [], active: await getActiveSaleListings() }; } return getSalesForZoo(zoo.id); } router.get("/", optionalSignature(), async (req, res, next) => { try { const data = await getSalesResponse(req); res.json(data); } catch (e) { next(e); } }); /** * @param {unknown} body * @returns {{ status: number, error: string } | null} */ function validateCreateListingBody(body) { const { animalId, isBaby, price, endAt } = body ?? {}; if (typeof animalId !== "string" || !animalId.trim()) return { status: 400, error: "animalId required" }; if (typeof isBaby !== "boolean") return { status: 400, error: "isBaby boolean required" }; const initialPrice = Number(price); if (!Number.isFinite(initialPrice) || initialPrice < 0) return { status: 400, error: "price must be a non-negative number" }; const endAtDate = endAt ? new Date(endAt) : null; if (!endAtDate || Number.isNaN(endAtDate.getTime())) return { status: 400, error: "endAt required (ISO date string)" }; return null; } /** * @param {unknown} body * @returns {{ ok: true, animalId: string, isBaby: boolean, initialPrice: number, endAtDate: Date, reproductionScoreAtSale?: number } | { ok: false, status: number, error: string }} */ function parseCreateListingBody(body) { const err = validateCreateListingBody(body); if (err) return { ok: false, status: err.status, error: err.error }; const { animalId, isBaby, price, endAt, reproductionScoreAtSale } = body ?? {}; const endAtDate = endAt ? new Date(endAt) : null; const initialPrice = Number(price); const repScore = reproductionScoreAtSale !== null && reproductionScoreAtSale !== undefined ? Number(reproductionScoreAtSale) : undefined; return { ok: true, animalId: String(animalId).trim(), isBaby, initialPrice, endAtDate: /** @type {Date} */ (endAtDate), reproductionScoreAtSale: repScore }; } /** POST /api/sales — create listing (auth). Body: { animalId, isBaby, price, endAt, reproductionScoreAtSale? }. */ router.post("/", requireSignature(), async (req, res, next) => { try { const zoo = await getZooByAccountId(req.account.id); if (!zoo) { res.status(404).json({ error: "No zoo for this account" }); return; } const parsed = parseCreateListingBody(req.body); if (!parsed.ok) { res.status(parsed.status).json({ error: parsed.error }); return; } const { id } = await createSaleListing({ sellerZooId: zoo.id, animalId: parsed.animalId, isBaby: parsed.isBaby, initialPrice: parsed.initialPrice, endAt: parsed.endAtDate.toISOString(), reproductionScoreAtSale: parsed.reproductionScoreAtSale, }); res.status(201).json({ id }); } catch (e) { next(e); } }); /** POST /api/sales/:id/bid — place bid (auth). Body: { amount }. */ router.post("/:id/bid", requireSignature(), async (req, res, next) => { try { const zoo = await getZooByAccountId(req.account.id); if (!zoo) { res.status(404).json({ error: "No zoo for this account" }); return; } const amount = Number(req.body?.amount); if (!Number.isFinite(amount) || amount < 0) { res.status(400).json({ error: "amount must be a non-negative number" }); return; } const result = await placeBid(req.params.id, zoo.id, amount); if (!result.ok) { res.status(400).json({ error: result.reason ?? "Bid failed" }); return; } res.json({ ok: true }); } catch (e) { next(e); } }); /** POST /api/sales/:id/accept — seller accepts best bid (auth). */ router.post("/:id/accept", requireSignature(), async (req, res, next) => { try { const zoo = await getZooByAccountId(req.account.id); if (!zoo) { res.status(404).json({ error: "No zoo for this account" }); return; } const result = await acceptSale(req.params.id, zoo.id); if (!result.ok) { res.status(400).json({ error: result.reason ?? "Accept failed" }); return; } res.json({ ok: true }); } catch (e) { next(e); } }); /** POST /api/sales/:id/reject — seller rejects current best bid (auth). */ router.post("/:id/reject", requireSignature(), async (req, res, next) => { try { const zoo = await getZooByAccountId(req.account.id); if (!zoo) { res.status(404).json({ error: "No zoo for this account" }); return; } const result = await rejectSale(req.params.id, zoo.id); if (!result.ok) { res.status(400).json({ error: result.reason ?? "Reject failed" }); return; } res.json({ ok: true }); } catch (e) { next(e); } }); /** POST /api/sales/:id/deliver — buyer marks as delivered (auth). */ router.post("/:id/deliver", requireSignature(), async (req, res, next) => { try { const zoo = await getZooByAccountId(req.account.id); if (!zoo) { res.status(404).json({ error: "No zoo for this account" }); return; } const result = await markSaleDelivered(req.params.id, zoo.id); if (!result.ok) { res.status(400).json({ error: result.reason ?? "Deliver failed" }); return; } res.json({ ok: true }); } catch (e) { next(e); } }); export default router;