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; 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; } const now = Date.now(); const ts = new Date(timestamp).getTime(); if (Number.isNaN(ts) || Math.abs(now - ts) > TIMESTAMP_TOLERANCE_MS) { res.status(401).json({ error: "Invalid or expired timestamp" }); return; } const body = req.bodyRaw !== undefined && req.bodyRaw !== null ? req.bodyRaw : ""; const bodyHash = hashBody(body); const path = req.originalUrl || req.baseUrl + req.path || req.path; const message = buildSignMessage(req.method, path, timestamp, bodyHash); if (!verifySignature(publicKey, signature, message)) { 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; } const now = Date.now(); const ts = new Date(timestamp).getTime(); if (Number.isNaN(ts) || Math.abs(now - ts) > TIMESTAMP_TOLERANCE_MS) { next(); return; } const body = req.bodyRaw !== undefined && req.bodyRaw !== null ? req.bodyRaw : ""; const bodyHash = hashBody(body); const path = req.originalUrl || req.baseUrl + req.path || req.path; const message = buildSignMessage(req.method, path, timestamp, bodyHash); if (!verifySignature(publicKey, signature, message)) { 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. */ router.get("/", optionalSignature(), async (req, res, next) => { try { await expireSaleListings(); if (req.account) { await processValidatedSales(); const zoo = await getZooByAccountId(req.account.id); if (!zoo) { res.json({ asSeller: [], asBuyerUndelivered: [], active: await getActiveSaleListings() }); return; } const data = await getSalesForZoo(zoo.id); res.json(data); return; } const active = await getActiveSaleListings(); res.json({ active }); } catch (e) { next(e); } }); /** 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 { animalId, isBaby, price, endAt, reproductionScoreAtSale } = req.body ?? {}; if (typeof animalId !== "string" || !animalId.trim()) { res.status(400).json({ error: "animalId required" }); return; } if (typeof isBaby !== "boolean") { res.status(400).json({ error: "isBaby boolean required" }); return; } const initialPrice = Number(price); if (!Number.isFinite(initialPrice) || initialPrice < 0) { res.status(400).json({ error: "price must be a non-negative number" }); return; } const endAtDate = endAt ? new Date(endAt) : null; if (!endAtDate || Number.isNaN(endAtDate.getTime())) { res.status(400).json({ error: "endAt required (ISO date string)" }); return; } const { id } = await createSaleListing({ sellerZooId: zoo.id, animalId: animalId.trim(), isBaby, initialPrice, endAt: endAtDate.toISOString(), reproductionScoreAtSale: reproductionScoreAtSale != null ? Number(reproductionScoreAtSale) : undefined, }); 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;