From 028345cc4c3abe0eb64d8827e394bd655d3c4d41 Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:14:24 +0100 Subject: [PATCH 01/20] chore(backend): install helmet, express-rate-limit, zod, vitest, supertest --- backend/package.json | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/package.json b/backend/package.json index ea044fc..2af0b74 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", - "start": "node dist/index.js" + "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@stellar/stellar-sdk": "14.2.0", @@ -15,14 +17,20 @@ "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", + "express-rate-limit": "^7.5.0", "groq-sdk": "^0.12.0", - "multer": "^2.1.1" + "helmet": "^8.0.0", + "multer": "^2.1.1", + "zod": "^3.24.0" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^22.10.5", + "@types/supertest": "^6.0.2", + "supertest": "^7.0.0", "tsx": "^4.19.2", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vitest": "^2.1.0" } } From 494836bb0da84db69174ca6b2a4c31c6b2fead12 Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:14:45 +0100 Subject: [PATCH 02/20] feat(backend): add helmet security-headers middleware --- backend/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/index.ts b/backend/src/index.ts index 55c811a..915e27b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,6 +1,7 @@ import "dotenv/config"; import cors from "cors"; import express from "express"; +import helmet from "helmet"; import { requireApiSecret } from "./middleware/auth.js"; import { aiRouter } from "./routes/ai.js"; import { notificationsRouter } from "./routes/notifications.js"; @@ -12,6 +13,7 @@ const app = express(); const port = Number(process.env.PORT) || 8787; const apiSecret = process.env.API_SECRET; +app.use(helmet()); app.use( cors({ origin: true, From d390ca9ad58b8df901760b57143b126981ad040e Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:15:15 +0100 Subject: [PATCH 03/20] =?UTF-8?q?feat(backend):=20add=20global=20rate=20li?= =?UTF-8?q?miter=20=E2=80=94=20100=20req=20/=2015=20min=20per=20IP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/index.ts | 2 ++ backend/src/middleware/rateLimiter.ts | 9 +++++++++ 2 files changed, 11 insertions(+) create mode 100644 backend/src/middleware/rateLimiter.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 915e27b..d1435f7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -3,6 +3,7 @@ import cors from "cors"; import express from "express"; import helmet from "helmet"; import { requireApiSecret } from "./middleware/auth.js"; +import { rateLimiter } from "./middleware/rateLimiter.js"; import { aiRouter } from "./routes/ai.js"; import { notificationsRouter } from "./routes/notifications.js"; import { uploadRouter } from "./routes/upload.js"; @@ -14,6 +15,7 @@ const port = Number(process.env.PORT) || 8787; const apiSecret = process.env.API_SECRET; app.use(helmet()); +app.use(rateLimiter); app.use( cors({ origin: true, diff --git a/backend/src/middleware/rateLimiter.ts b/backend/src/middleware/rateLimiter.ts new file mode 100644 index 0000000..bb4f34d --- /dev/null +++ b/backend/src/middleware/rateLimiter.ts @@ -0,0 +1,9 @@ +import rateLimit from "express-rate-limit"; + +export const rateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 100, + standardHeaders: "draft-7", + legacyHeaders: false, + message: { error: "Too many requests, please try again later" }, +}); From 0fa8b0a28b6d13eacf1403d6499dec5ea2d73397 Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:15:32 +0100 Subject: [PATCH 04/20] feat(backend): add validateBody/validateQuery Zod middleware helpers --- backend/src/middleware/validate.ts | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 backend/src/middleware/validate.ts diff --git a/backend/src/middleware/validate.ts b/backend/src/middleware/validate.ts new file mode 100644 index 0000000..d0d7e5d --- /dev/null +++ b/backend/src/middleware/validate.ts @@ -0,0 +1,35 @@ +import type { Request, Response, NextFunction } from "express"; +import { type ZodSchema } from "zod"; + +function formatError(errors: { path: (string | number)[]; message: string }[]) { + return { + error: "Validation failed", + details: errors.map((e) => ({ + field: e.path.join(".") || "root", + message: e.message, + })), + }; +} + +export function validateBody(schema: ZodSchema) { + return (req: Request, res: Response, next: NextFunction): void => { + const result = schema.safeParse(req.body); + if (!result.success) { + res.status(400).json(formatError(result.error.errors)); + return; + } + req.body = result.data; + next(); + }; +} + +export function validateQuery(schema: ZodSchema) { + return (req: Request, res: Response, next: NextFunction): void => { + const result = schema.safeParse(req.query); + if (!result.success) { + res.status(400).json(formatError(result.error.errors)); + return; + } + next(); + }; +} From 9d5bada7ca2433a6b4d735abd47423cdb7d00c58 Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:15:50 +0100 Subject: [PATCH 05/20] feat(backend): add shared internalError/serviceUnavailable response helpers --- backend/src/lib/errors.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 backend/src/lib/errors.ts diff --git a/backend/src/lib/errors.ts b/backend/src/lib/errors.ts new file mode 100644 index 0000000..171954a --- /dev/null +++ b/backend/src/lib/errors.ts @@ -0,0 +1,9 @@ +import type { Response } from "express"; + +export function internalError(res: Response): void { + res.status(500).json({ error: "An internal error occurred" }); +} + +export function serviceUnavailable(res: Response, service: string): void { + res.status(503).json({ error: `${service} is not configured` }); +} From 6cad3aff3067b1a93f54c931f6193889c9e91e24 Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:16:12 +0100 Subject: [PATCH 06/20] feat(backend/schemas): add Zod schemas for all notifications routes --- backend/src/schemas/notifications.ts | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 backend/src/schemas/notifications.ts diff --git a/backend/src/schemas/notifications.ts b/backend/src/schemas/notifications.ts new file mode 100644 index 0000000..54f7cf9 --- /dev/null +++ b/backend/src/schemas/notifications.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +const STELLAR_ADDR = /^G[A-Z0-9]{55}$/; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const stellarWallet = z + .string() + .regex(STELLAR_ADDR, "must be a valid Stellar G-address"); + +export const getNotificationsQuery = z.object({ + wallet: stellarWallet, +}); + +export const patchNotificationReadQuery = z.object({ + wallet: stellarWallet, +}); + +export const patchNotificationReadParams = z.object({ + id: z.string().regex(UUID_RE, "Invalid notification id"), +}); + +export const postNotificationBody = z.object({ + wallet_address: stellarWallet, + type: z.string().min(1, "type is required").max(50), + title: z.string().min(1, "title is required").max(200), + message: z.string().min(1, "message is required").max(1000), + action_url: z.string().url("action_url must be a valid URL").optional().nullable(), + data: z.record(z.unknown()).optional(), +}); From 22b1e2c71d60db7d75e2260d3044e2965d3687b3 Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:16:49 +0100 Subject: [PATCH 07/20] feat(backend/notifications): apply Zod validation to GET /notifications --- backend/src/routes/notifications.ts | 245 ++++++++++++++-------------- 1 file changed, 119 insertions(+), 126 deletions(-) diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index a633e4f..46e43b2 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -1,131 +1,124 @@ import { Router } from "express"; import { getSupabase } from "../lib/supabase.js"; +import { internalError, serviceUnavailable } from "../lib/errors.js"; +import { validateBody, validateQuery } from "../middleware/validate.js"; +import { + getNotificationsQuery, + patchNotificationReadQuery, + patchNotificationReadParams, + postNotificationBody, +} from "../schemas/notifications.js"; export const notificationsRouter = Router(); -const UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - -notificationsRouter.get("/", async (req, res) => { - const supabase = getSupabase(); - if (!supabase) { - res.json({ notifications: [] }); - return; - } - - const wallet = String(req.query.wallet ?? "").trim(); - if (!wallet || !wallet.startsWith("G")) { - res.status(400).json({ error: "wallet query must be a Stellar G-address" }); - return; - } - - const { data, error } = await supabase - .from("notifications") - .select( - "id, wallet_address, type, title, message, read_at, action_url, data, created_at", - ) - .eq("wallet_address", wallet) - .order("created_at", { ascending: false }) - .limit(100); - - if (error) { - res.status(500).json({ error: error.message }); - return; - } - - const notifications = (data ?? []).map((row) => ({ - id: row.id, - type: row.type, - title: row.title, - message: row.message, - read: !!row.read_at, - timestamp: row.created_at, - actionUrl: row.action_url ?? undefined, - data: row.data ?? {}, - })); - - res.json({ notifications }); -}); - -notificationsRouter.patch("/:id/read", async (req, res) => { - const supabase = getSupabase(); - if (!supabase) { - res.status(503).json({ error: "Notifications store not configured" }); - return; - } - - const id = req.params.id; - if (!UUID_RE.test(id)) { - res.status(400).json({ error: "Invalid notification id" }); - return; - } - - const wallet = String(req.query.wallet ?? "").trim(); - if (!wallet || !wallet.startsWith("G")) { - res.status(400).json({ error: "wallet query must be a Stellar G-address" }); - return; - } - - const { error } = await supabase - .from("notifications") - .update({ read_at: new Date().toISOString() }) - .eq("id", id) - .eq("wallet_address", wallet); - - if (error) { - res.status(500).json({ error: error.message }); - return; - } - - res.json({ ok: true }); -}); - -notificationsRouter.post("/", async (req, res) => { - const supabase = getSupabase(); - if (!supabase) { - res.status(503).json({ error: "Notifications store not configured" }); - return; - } - - const { - wallet_address, - type, - title, - message, - action_url, - data: payload, - } = req.body ?? {}; - - const wallet = String(wallet_address ?? "").trim(); - if (!wallet || !wallet.startsWith("G")) { - res - .status(400) - .json({ error: "wallet_address must be a Stellar G-address" }); - return; - } - - if (!type || !title || !message) { - res.status(400).json({ error: "type, title, and message are required" }); - return; - } - - const { data, error } = await supabase - .from("notifications") - .insert({ - wallet_address: wallet, - type: String(type), - title: String(title), - message: String(message), - action_url: action_url ? String(action_url) : null, - data: payload && typeof payload === "object" ? payload : {}, - }) - .select("id") - .single(); - - if (error) { - res.status(500).json({ error: error.message }); - return; - } - - res.status(201).json({ id: data.id }); -}); +notificationsRouter.get( + "/", + validateQuery(getNotificationsQuery), + async (req, res) => { + const supabase = getSupabase(); + if (!supabase) { + res.json({ notifications: [] }); + return; + } + + const wallet = String(req.query.wallet ?? "").trim(); + + const { data, error } = await supabase + .from("notifications") + .select( + "id, wallet_address, type, title, message, read_at, action_url, data, created_at", + ) + .eq("wallet_address", wallet) + .order("created_at", { ascending: false }) + .limit(100); + + if (error) { + internalError(res); + return; + } + + const notifications = (data ?? []).map((row) => ({ + id: row.id, + type: row.type, + title: row.title, + message: row.message, + read: !!row.read_at, + timestamp: row.created_at, + actionUrl: row.action_url ?? undefined, + data: row.data ?? {}, + })); + + res.json({ notifications }); + }, +); + +notificationsRouter.patch( + "/:id/read", + validateQuery(patchNotificationReadQuery), + async (req, res) => { + const supabase = getSupabase(); + if (!supabase) { + serviceUnavailable(res, "Notifications store"); + return; + } + + const idResult = patchNotificationReadParams.safeParse({ id: req.params.id }); + if (!idResult.success) { + res.status(400).json({ + error: "Validation failed", + details: [{ field: "id", message: "Invalid notification id" }], + }); + return; + } + + const wallet = String(req.query.wallet ?? "").trim(); + + const { error } = await supabase + .from("notifications") + .update({ read_at: new Date().toISOString() }) + .eq("id", idResult.data.id) + .eq("wallet_address", wallet); + + if (error) { + internalError(res); + return; + } + + res.json({ ok: true }); + }, +); + +notificationsRouter.post( + "/", + validateBody(postNotificationBody), + async (req, res) => { + const supabase = getSupabase(); + if (!supabase) { + serviceUnavailable(res, "Notifications store"); + return; + } + + const { wallet_address, type, title, message, action_url, data: payload } = + req.body; + + const { data, error } = await supabase + .from("notifications") + .insert({ + wallet_address, + type, + title, + message, + action_url: action_url ?? null, + data: payload ?? {}, + }) + .select("id") + .single(); + + if (error) { + internalError(res); + return; + } + + res.status(201).json({ id: data.id }); + }, +); From ceeed5a0cc94d1fc5e97d5209e3abc77709ae4e3 Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:17:24 +0100 Subject: [PATCH 08/20] feat(backend/schemas): add Zod schemas for all messages routes --- backend/src/schemas/messages.ts | 48 +++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 backend/src/schemas/messages.ts diff --git a/backend/src/schemas/messages.ts b/backend/src/schemas/messages.ts new file mode 100644 index 0000000..f85aec0 --- /dev/null +++ b/backend/src/schemas/messages.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +const STELLAR_ADDR = /^G[A-Z0-9]{55}$/; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const stellarWallet = z + .string() + .regex(STELLAR_ADDR, "must be a valid Stellar G-address"); + +export const postMessageBody = z + .object({ + sender_address: stellarWallet, + recipient_address: stellarWallet, + content: z.string().min(1, "content is required").max(4000), + }) + .refine((d) => d.sender_address !== d.recipient_address, { + message: "Cannot message yourself", + path: ["recipient_address"], + }); + +export const getConversationQuery = z.object({ + a: stellarWallet, + b: stellarWallet, + since: z.string().datetime({ offset: true }).optional(), +}); + +export const getInboxQuery = z.object({ + wallet: stellarWallet, +}); + +export const getUnreadCountQuery = z.object({ + wallet: stellarWallet, +}); + +export const patchConversationReadQuery = z.object({ + a: stellarWallet, + b: stellarWallet, + wallet: stellarWallet, +}); + +export const patchMessageReadParams = z.object({ + id: z.string().regex(UUID_RE, "Invalid message id"), +}); + +export const patchMessageReadQuery = z.object({ + wallet: stellarWallet, +}); From a9a93d69e74d5788befe6632f89a375cc79d7a0b Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:18:00 +0100 Subject: [PATCH 09/20] feat(backend/messages): replace manual checks with Zod validation on POST / --- backend/src/routes/messages.ts | 53 ++++++++++++---------------------- 1 file changed, 18 insertions(+), 35 deletions(-) diff --git a/backend/src/routes/messages.ts b/backend/src/routes/messages.ts index 6614f32..098a69f 100644 --- a/backend/src/routes/messages.ts +++ b/backend/src/routes/messages.ts @@ -1,64 +1,47 @@ import { Router } from "express"; import { getSupabase } from "../lib/supabase.js"; +import { internalError, serviceUnavailable } from "../lib/errors.js"; +import { validateBody, validateQuery } from "../middleware/validate.js"; +import { + postMessageBody, + getConversationQuery, + getInboxQuery, + getUnreadCountQuery, + patchConversationReadQuery, + patchMessageReadParams, + patchMessageReadQuery, +} from "../schemas/messages.js"; export const messagesRouter = Router(); -const STELLAR_ADDR = /^G[A-Z0-9]{55}$/; -const UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - function conversationId(a: string, b: string): string { return [a, b].sort().join(":"); } // POST /v1/messages — send a message -messagesRouter.post("/", async (req, res) => { +messagesRouter.post("/", validateBody(postMessageBody), async (req, res) => { const supabase = getSupabase(); if (!supabase) { - res.status(503).json({ error: "Messages store not configured" }); - return; - } - - const { sender_address, recipient_address, content } = req.body ?? {}; - - if ( - !sender_address || - !STELLAR_ADDR.test(String(sender_address)) || - !recipient_address || - !STELLAR_ADDR.test(String(recipient_address)) - ) { - res.status(400).json({ - error: - "sender_address and recipient_address must be valid Stellar G-addresses", - }); - return; - } - - if (!content || typeof content !== "string" || !content.trim()) { - res.status(400).json({ error: "content is required" }); - return; - } - - if (sender_address === recipient_address) { - res.status(400).json({ error: "Cannot message yourself" }); + serviceUnavailable(res, "Messages store"); return; } + const { sender_address, recipient_address, content } = req.body; const convId = conversationId(sender_address, recipient_address); const { data, error } = await supabase .from("messages") .insert({ conversation_id: convId, - sender_address: String(sender_address), - recipient_address: String(recipient_address), - content: content.trim().slice(0, 4000), + sender_address, + recipient_address, + content: content.trim(), }) .select("id, created_at") .single(); if (error) { - res.status(500).json({ error: error.message }); + internalError(res); return; } From ee15d15f48ae75080eb5ddcadfd8878862f4c786 Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:18:47 +0100 Subject: [PATCH 10/20] feat(backend/messages): apply Zod validation to GET /conversation --- backend/src/routes/messages.ts | 71 ++++++++++++++++------------------ 1 file changed, 34 insertions(+), 37 deletions(-) diff --git a/backend/src/routes/messages.ts b/backend/src/routes/messages.ts index 098a69f..cbdde4e 100644 --- a/backend/src/routes/messages.ts +++ b/backend/src/routes/messages.ts @@ -49,46 +49,43 @@ messagesRouter.post("/", validateBody(postMessageBody), async (req, res) => { }); // GET /v1/messages/conversation?a=ADDR1&b=ADDR2&since=ISO — fetch chat thread -messagesRouter.get("/conversation", async (req, res) => { - const supabase = getSupabase(); - if (!supabase) { - res.json({ messages: [] }); - return; - } - - const a = String(req.query.a ?? "").trim(); - const b = String(req.query.b ?? "").trim(); - const since = String(req.query.since ?? "").trim(); - - if (!STELLAR_ADDR.test(a) || !STELLAR_ADDR.test(b)) { - res - .status(400) - .json({ error: "a and b must be valid Stellar G-addresses" }); - return; - } - - const convId = conversationId(a, b); - let query = supabase - .from("messages") - .select( - "id, sender_address, recipient_address, content, read_at, created_at", - ) - .eq("conversation_id", convId) - .order("created_at", { ascending: true }) - .limit(200); +messagesRouter.get( + "/conversation", + validateQuery(getConversationQuery), + async (req, res) => { + const supabase = getSupabase(); + if (!supabase) { + res.json({ messages: [] }); + return; + } - if (since) { - query = query.gt("created_at", since); - } + const a = String(req.query.a ?? "").trim(); + const b = String(req.query.b ?? "").trim(); + const since = String(req.query.since ?? "").trim(); + const convId = conversationId(a, b); + + let query = supabase + .from("messages") + .select( + "id, sender_address, recipient_address, content, read_at, created_at", + ) + .eq("conversation_id", convId) + .order("created_at", { ascending: true }) + .limit(200); + + if (since) { + query = query.gt("created_at", since); + } - const { data, error } = await query; - if (error) { - res.status(500).json({ error: error.message }); - return; - } + const { data, error } = await query; + if (error) { + internalError(res); + return; + } - res.json({ messages: data ?? [] }); -}); + res.json({ messages: data ?? [] }); + }, +); // GET /v1/messages/inbox?wallet=ADDR — list all conversations with latest message + unread count messagesRouter.get("/inbox", async (req, res) => { From ed183ac9de2541de1bf857c0420863ac8b2125c2 Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:19:13 +0100 Subject: [PATCH 11/20] feat(backend/messages): apply Zod validation to GET /inbox --- backend/src/routes/messages.ts | 113 ++++++++++++++++----------------- 1 file changed, 56 insertions(+), 57 deletions(-) diff --git a/backend/src/routes/messages.ts b/backend/src/routes/messages.ts index cbdde4e..94766e5 100644 --- a/backend/src/routes/messages.ts +++ b/backend/src/routes/messages.ts @@ -88,69 +88,68 @@ messagesRouter.get( ); // GET /v1/messages/inbox?wallet=ADDR — list all conversations with latest message + unread count -messagesRouter.get("/inbox", async (req, res) => { - const supabase = getSupabase(); - if (!supabase) { - res.json({ conversations: [] }); - return; - } - - const wallet = String(req.query.wallet ?? "").trim(); - if (!STELLAR_ADDR.test(wallet)) { - res.status(400).json({ error: "wallet must be a valid Stellar G-address" }); - return; - } +messagesRouter.get( + "/inbox", + validateQuery(getInboxQuery), + async (req, res) => { + const supabase = getSupabase(); + if (!supabase) { + res.json({ conversations: [] }); + return; + } - // Fetch messages where user is sender OR recipient, ordered by newest first - const { data, error } = await supabase - .from("messages") - .select( - "id, conversation_id, sender_address, recipient_address, content, read_at, created_at", - ) - .or(`sender_address.eq.${wallet},recipient_address.eq.${wallet}`) - .order("created_at", { ascending: false }) - .limit(500); + const wallet = String(req.query.wallet ?? "").trim(); - if (error) { - res.status(500).json({ error: error.message }); - return; - } + // Fetch messages where user is sender OR recipient, ordered by newest first + const { data, error } = await supabase + .from("messages") + .select( + "id, conversation_id, sender_address, recipient_address, content, read_at, created_at", + ) + .or(`sender_address.eq.${wallet},recipient_address.eq.${wallet}`) + .order("created_at", { ascending: false }) + .limit(500); - // Group by conversation, keep latest message + unread count - const convMap = new Map< - string, - { - conversation_id: string; - other_address: string; - latest_message: string; - latest_at: string; - unread: number; - } - >(); - - for (const row of data ?? []) { - const other = - row.sender_address === wallet - ? row.recipient_address - : row.sender_address; - if (!convMap.has(row.conversation_id)) { - convMap.set(row.conversation_id, { - conversation_id: row.conversation_id, - other_address: other, - latest_message: row.content, - latest_at: row.created_at, - unread: 0, - }); + if (error) { + internalError(res); + return; } - // Count unread: messages sent TO this wallet that have no read_at - if (row.recipient_address === wallet && !row.read_at) { - const entry = convMap.get(row.conversation_id)!; - entry.unread++; + + // Group by conversation, keep latest message + unread count + const convMap = new Map< + string, + { + conversation_id: string; + other_address: string; + latest_message: string; + latest_at: string; + unread: number; + } + >(); + + for (const row of data ?? []) { + const other = + row.sender_address === wallet + ? row.recipient_address + : row.sender_address; + if (!convMap.has(row.conversation_id)) { + convMap.set(row.conversation_id, { + conversation_id: row.conversation_id, + other_address: other, + latest_message: row.content, + latest_at: row.created_at, + unread: 0, + }); + } + if (row.recipient_address === wallet && !row.read_at) { + const entry = convMap.get(row.conversation_id)!; + entry.unread++; + } } - } - res.json({ conversations: Array.from(convMap.values()) }); -}); + res.json({ conversations: Array.from(convMap.values()) }); + }, +); // GET /v1/messages/unread-count?wallet=ADDR — total unread count for badge messagesRouter.get("/unread-count", async (req, res) => { From f5867e5c94434230e47a131c9c7e06d722c8d0e2 Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:19:44 +0100 Subject: [PATCH 12/20] feat(backend/messages): apply Zod validation to GET /unread-count --- backend/src/routes/messages.ts | 44 +++++++++++++++++----------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/backend/src/routes/messages.ts b/backend/src/routes/messages.ts index 94766e5..d9134ef 100644 --- a/backend/src/routes/messages.ts +++ b/backend/src/routes/messages.ts @@ -152,32 +152,32 @@ messagesRouter.get( ); // GET /v1/messages/unread-count?wallet=ADDR — total unread count for badge -messagesRouter.get("/unread-count", async (req, res) => { - const supabase = getSupabase(); - if (!supabase) { - res.json({ count: 0 }); - return; - } +messagesRouter.get( + "/unread-count", + validateQuery(getUnreadCountQuery), + async (req, res) => { + const supabase = getSupabase(); + if (!supabase) { + res.json({ count: 0 }); + return; + } - const wallet = String(req.query.wallet ?? "").trim(); - if (!STELLAR_ADDR.test(wallet)) { - res.status(400).json({ error: "wallet must be a valid Stellar G-address" }); - return; - } + const wallet = String(req.query.wallet ?? "").trim(); - const { count, error } = await supabase - .from("messages") - .select("id", { count: "exact", head: true }) - .eq("recipient_address", wallet) - .is("read_at", null); + const { count, error } = await supabase + .from("messages") + .select("id", { count: "exact", head: true }) + .eq("recipient_address", wallet) + .is("read_at", null); - if (error) { - res.status(500).json({ error: error.message }); - return; - } + if (error) { + internalError(res); + return; + } - res.json({ count: count ?? 0 }); -}); + res.json({ count: count ?? 0 }); + }, +); // PATCH /v1/messages/conversation/read?a=ADDR1&b=ADDR2&wallet=ADDR — mark all messages in thread as read messagesRouter.patch("/conversation/read", async (req, res) => { From 50b9db4bdface67ab6b3e01fc78f7c1dfc4d40cf Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:20:21 +0100 Subject: [PATCH 13/20] feat(backend/messages): apply Zod validation to PATCH /conversation/read --- backend/src/routes/messages.ts | 59 +++++++++++++++------------------- 1 file changed, 26 insertions(+), 33 deletions(-) diff --git a/backend/src/routes/messages.ts b/backend/src/routes/messages.ts index d9134ef..eaf1074 100644 --- a/backend/src/routes/messages.ts +++ b/backend/src/routes/messages.ts @@ -180,43 +180,36 @@ messagesRouter.get( ); // PATCH /v1/messages/conversation/read?a=ADDR1&b=ADDR2&wallet=ADDR — mark all messages in thread as read -messagesRouter.patch("/conversation/read", async (req, res) => { - const supabase = getSupabase(); - if (!supabase) { - res.status(503).json({ error: "Messages store not configured" }); - return; - } - - const a = String(req.query.a ?? "").trim(); - const b = String(req.query.b ?? "").trim(); - const wallet = String(req.query.wallet ?? "").trim(); +messagesRouter.patch( + "/conversation/read", + validateQuery(patchConversationReadQuery), + async (req, res) => { + const supabase = getSupabase(); + if (!supabase) { + serviceUnavailable(res, "Messages store"); + return; + } - if ( - !STELLAR_ADDR.test(a) || - !STELLAR_ADDR.test(b) || - !STELLAR_ADDR.test(wallet) - ) { - res - .status(400) - .json({ error: "a, b, and wallet must be valid Stellar G-addresses" }); - return; - } + const a = String(req.query.a ?? "").trim(); + const b = String(req.query.b ?? "").trim(); + const wallet = String(req.query.wallet ?? "").trim(); + const convId = conversationId(a, b); - const convId = conversationId(a, b); - const { error } = await supabase - .from("messages") - .update({ read_at: new Date().toISOString() }) - .eq("conversation_id", convId) - .eq("recipient_address", wallet) - .is("read_at", null); + const { error } = await supabase + .from("messages") + .update({ read_at: new Date().toISOString() }) + .eq("conversation_id", convId) + .eq("recipient_address", wallet) + .is("read_at", null); - if (error) { - res.status(500).json({ error: error.message }); - return; - } + if (error) { + internalError(res); + return; + } - res.json({ ok: true }); -}); + res.json({ ok: true }); + }, +); // PATCH /v1/messages/:id/read?wallet=ADDR — mark single message as read messagesRouter.patch("/:id/read", async (req, res) => { From c11f53cdc7f96bc4fd2ca7feda5a383a01d0c32f Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:21:22 +0100 Subject: [PATCH 14/20] feat(backend/messages): apply Zod validation to PATCH /:id/read; sanitize Supabase errors --- backend/src/routes/messages.ts | 59 ++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/backend/src/routes/messages.ts b/backend/src/routes/messages.ts index eaf1074..331f19a 100644 --- a/backend/src/routes/messages.ts +++ b/backend/src/routes/messages.ts @@ -212,36 +212,39 @@ messagesRouter.patch( ); // PATCH /v1/messages/:id/read?wallet=ADDR — mark single message as read -messagesRouter.patch("/:id/read", async (req, res) => { - const supabase = getSupabase(); - if (!supabase) { - res.status(503).json({ error: "Messages store not configured" }); - return; - } +messagesRouter.patch( + "/:id/read", + validateQuery(patchMessageReadQuery), + async (req, res) => { + const supabase = getSupabase(); + if (!supabase) { + serviceUnavailable(res, "Messages store"); + return; + } - const id = req.params.id; - if (!UUID_RE.test(id)) { - res.status(400).json({ error: "Invalid message id" }); - return; - } + const idResult = patchMessageReadParams.safeParse({ id: req.params.id }); + if (!idResult.success) { + res.status(400).json({ + error: "Validation failed", + details: [{ field: "id", message: "Invalid message id" }], + }); + return; + } - const wallet = String(req.query.wallet ?? "").trim(); - if (!STELLAR_ADDR.test(wallet)) { - res.status(400).json({ error: "wallet must be a valid Stellar G-address" }); - return; - } + const wallet = String(req.query.wallet ?? "").trim(); - const { error } = await supabase - .from("messages") - .update({ read_at: new Date().toISOString() }) - .eq("id", id) - .eq("recipient_address", wallet) - .is("read_at", null); + const { error } = await supabase + .from("messages") + .update({ read_at: new Date().toISOString() }) + .eq("id", idResult.data.id) + .eq("recipient_address", wallet) + .is("read_at", null); - if (error) { - res.status(500).json({ error: error.message }); - return; - } + if (error) { + internalError(res); + return; + } - res.json({ ok: true }); -}); + res.json({ ok: true }); + }, +); From b2ef454a689f9cbf277adf66f3f2bbb866e251b7 Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:21:36 +0100 Subject: [PATCH 15/20] feat(backend/schemas): add Zod schemas for all AI route bodies --- backend/src/schemas/ai.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 backend/src/schemas/ai.ts diff --git a/backend/src/schemas/ai.ts b/backend/src/schemas/ai.ts new file mode 100644 index 0000000..6b818e2 --- /dev/null +++ b/backend/src/schemas/ai.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const postMilestonesBody = z.object({ + projectTitle: z.string().max(200).default(""), + projectDescription: z.string().max(5000).default(""), + totalBudget: z.string().max(50).default(""), + durationDays: z.string().max(20).default(""), + userPrompt: z.string().min(1, "userPrompt is required").max(2000), + milestoneIndex: z.number().int().nullable().default(null), +}); + +export const postCoverLetterBody = z.object({ + jobTitle: z.string().max(200).default(""), + jobDescription: z.string().min(1, "jobDescription is required").max(5000), + proposedTimelineDays: z.string().max(20).optional(), + tone: z.string().max(50).optional(), + userDraft: z.string().max(3000).optional(), +}); + +export const postRewriteBody = z.object({ + text: z.string().min(1, "text is required").max(5000), +}); From df437efca16813c535f72cb3aa24592923b85504 Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:22:21 +0100 Subject: [PATCH 16/20] feat(backend/ai): apply Zod validation to POST /milestones --- backend/src/routes/ai.ts | 42 ++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/backend/src/routes/ai.ts b/backend/src/routes/ai.ts index d171867..2c16bd9 100644 --- a/backend/src/routes/ai.ts +++ b/backend/src/routes/ai.ts @@ -4,40 +4,40 @@ import { generateMilestoneSuggestions, rewriteProjectDescription, } from "../lib/groq.js"; +import { validateBody } from "../middleware/validate.js"; +import { + postMilestonesBody, + postCoverLetterBody, + postRewriteBody, +} from "../schemas/ai.js"; export const aiRouter = Router(); -aiRouter.post("/milestones", async (req, res) => { +aiRouter.post("/milestones", validateBody(postMilestonesBody), async (req, res) => { try { const { - projectTitle = "", - projectDescription = "", - totalBudget = "", - durationDays = "", - userPrompt = "", - milestoneIndex = null, - } = req.body ?? {}; - - if (!String(userPrompt).trim()) { - res.status(400).json({ error: "userPrompt is required" }); - return; - } + projectTitle, + projectDescription, + totalBudget, + durationDays, + userPrompt, + milestoneIndex, + } = req.body; const suggestions = await generateMilestoneSuggestions({ - projectTitle: String(projectTitle), - projectDescription: String(projectDescription), - totalBudget: String(totalBudget), - durationDays: String(durationDays), - userPrompt: String(userPrompt), - milestoneIndex: - typeof milestoneIndex === "number" ? milestoneIndex : null, + projectTitle, + projectDescription, + totalBudget, + durationDays, + userPrompt, + milestoneIndex, }); res.json({ suggestions }); } catch (e) { const msg = e instanceof Error ? e.message : "AI generation failed"; const status = msg.includes("GROQ_API_KEY") ? 503 : 500; - res.status(status).json({ error: msg }); + res.status(status).json({ error: status === 503 ? "AI service not configured" : "AI generation failed" }); } }); From 061bdd46d1db5c69df765c784d90a7d887b3fa80 Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:22:51 +0100 Subject: [PATCH 17/20] feat(backend/ai): apply Zod validation to POST /cover-letter --- backend/src/routes/ai.ts | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/backend/src/routes/ai.ts b/backend/src/routes/ai.ts index 2c16bd9..5c9068f 100644 --- a/backend/src/routes/ai.ts +++ b/backend/src/routes/ai.ts @@ -41,36 +41,24 @@ aiRouter.post("/milestones", validateBody(postMilestonesBody), async (req, res) } }); -aiRouter.post("/cover-letter", async (req, res) => { +aiRouter.post("/cover-letter", validateBody(postCoverLetterBody), async (req, res) => { try { - const { - jobTitle = "", - jobDescription = "", - proposedTimelineDays = "", - tone = "", - userDraft = "", - } = req.body ?? {}; - - if (!String(jobDescription).trim()) { - res.status(400).json({ error: "jobDescription is required" }); - return; - } + const { jobTitle, jobDescription, proposedTimelineDays, tone, userDraft } = + req.body; const coverLetter = await generateCoverLetter({ - jobTitle: String(jobTitle), - jobDescription: String(jobDescription), - proposedTimelineDays: proposedTimelineDays - ? String(proposedTimelineDays) - : undefined, - tone: tone ? String(tone) : undefined, - userDraft: userDraft ? String(userDraft) : undefined, + jobTitle, + jobDescription, + proposedTimelineDays, + tone, + userDraft, }); res.json({ coverLetter }); } catch (e) { const msg = e instanceof Error ? e.message : "AI generation failed"; const status = msg.includes("GROQ_API_KEY") ? 503 : 500; - res.status(status).json({ error: msg }); + res.status(status).json({ error: status === 503 ? "AI service not configured" : "AI generation failed" }); } }); From 3ed169b29ec27c6571b5d036e294f8272cb373e9 Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:23:12 +0100 Subject: [PATCH 18/20] feat(backend/ai): apply Zod validation to POST /rewrite; sanitize Groq errors from 500 responses --- backend/src/routes/ai.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/backend/src/routes/ai.ts b/backend/src/routes/ai.ts index 5c9068f..ab2b02a 100644 --- a/backend/src/routes/ai.ts +++ b/backend/src/routes/ai.ts @@ -62,20 +62,14 @@ aiRouter.post("/cover-letter", validateBody(postCoverLetterBody), async (req, re } }); -aiRouter.post("/rewrite", async (req, res) => { +aiRouter.post("/rewrite", validateBody(postRewriteBody), async (req, res) => { try { - const { text = "" } = req.body ?? {}; - - if (!String(text).trim()) { - res.status(400).json({ error: "text is required" }); - return; - } - - const rewritten = await rewriteProjectDescription({ text: String(text) }); + const { text } = req.body; + const rewritten = await rewriteProjectDescription({ text }); res.json({ text: rewritten }); } catch (e) { const msg = e instanceof Error ? e.message : "AI rewrite failed"; const status = msg.includes("GROQ_API_KEY") ? 503 : 500; - res.status(status).json({ error: msg }); + res.status(status).json({ error: status === 503 ? "AI service not configured" : "AI rewrite failed" }); } }); From c6039d397161864c5a4d94cb54e0238574b5216c Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:25:04 +0100 Subject: [PATCH 19/20] feat(backend): add Zod schemas and validation for gasless and upload routes; sanitize errors --- backend/src/routes/gasless.ts | 17 ++++------------- backend/src/routes/upload.ts | 27 ++++++++++++++++++--------- backend/src/schemas/gasless.ts | 8 ++++++++ backend/src/schemas/upload.ts | 6 ++++++ 4 files changed, 36 insertions(+), 22 deletions(-) create mode 100644 backend/src/schemas/gasless.ts create mode 100644 backend/src/schemas/upload.ts diff --git a/backend/src/routes/gasless.ts b/backend/src/routes/gasless.ts index 816e11f..1c63714 100644 --- a/backend/src/routes/gasless.ts +++ b/backend/src/routes/gasless.ts @@ -20,6 +20,8 @@ import { Transaction, } from "@stellar/stellar-sdk"; import { Server as RpcServer } from "@stellar/stellar-sdk/rpc"; +import { validateBody } from "../middleware/validate.js"; +import { postGaslessApplyBody } from "../schemas/gasless.js"; export const gaslessRouter = Router(); @@ -70,22 +72,11 @@ function requireInnerTransaction( return parsed as Transaction; } -gaslessRouter.post("/apply", async (req, res) => { +gaslessRouter.post("/apply", validateBody(postGaslessApplyBody), async (req, res) => { try { - const { signedTxXdr } = req.body ?? {}; - - if (!signedTxXdr || typeof signedTxXdr !== "string") { - res.status(400).json({ error: "signedTxXdr is required" }); - return; - } + const { signedTxXdr } = req.body; const normalizedXdr = normalizeSignedTxXdr(signedTxXdr); - if (normalizedXdr.length < 48) { - res - .status(400) - .json({ error: "signedTxXdr is too short to be valid XDR" }); - return; - } const adminKeypair = getAdminKeypair(); const networkPassphrase = getNetworkPassphrase(); diff --git a/backend/src/routes/upload.ts b/backend/src/routes/upload.ts index 1cf4477..c6ce9d1 100644 --- a/backend/src/routes/upload.ts +++ b/backend/src/routes/upload.ts @@ -1,6 +1,8 @@ import { Router } from "express"; import multer from "multer"; import { getSupabase } from "../lib/supabase.js"; +import { internalError, serviceUnavailable } from "../lib/errors.js"; +import { uploadMilestoneBody } from "../schemas/upload.js"; export const uploadRouter = Router(); @@ -34,7 +36,7 @@ const upload = multer({ uploadRouter.post("/milestone", upload.single("file"), async (req, res) => { const supabase = getSupabase(); if (!supabase) { - res.status(503).json({ error: "Storage not configured" }); + serviceUnavailable(res, "Storage"); return; } @@ -44,8 +46,20 @@ uploadRouter.post("/milestone", upload.single("file"), async (req, res) => { return; } - const escrowId = String(req.body.escrow_id ?? "unknown"); - const milestoneIndex = String(req.body.milestone_index ?? "0"); + const bodyResult = uploadMilestoneBody.safeParse(req.body); + if (!bodyResult.success) { + res.status(400).json({ + error: "Validation failed", + details: bodyResult.error.errors.map((e) => ({ + field: e.path.join(".") || "root", + message: e.message, + })), + }); + return; + } + + const escrowId = String(bodyResult.data.escrow_id); + const milestoneIndex = String(bodyResult.data.milestone_index); const ext = file.originalname.split(".").pop() ?? "bin"; const path = `${escrowId}/${milestoneIndex}/${Date.now()}-${Math.random() .toString(36) @@ -66,12 +80,7 @@ uploadRouter.post("/milestone", upload.single("file"), async (req, res) => { }); if (uploadError) { - const hint = - uploadError.message.includes("row-level security") || - uploadError.message.includes("violates") - ? " — ensure the Supabase storage RLS policy allows inserts, or use the service_role key" - : ""; - res.status(500).json({ error: uploadError.message + hint }); + internalError(res); return; } diff --git a/backend/src/schemas/gasless.ts b/backend/src/schemas/gasless.ts new file mode 100644 index 0000000..6044939 --- /dev/null +++ b/backend/src/schemas/gasless.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const postGaslessApplyBody = z.object({ + signedTxXdr: z + .string() + .min(48, "signedTxXdr is too short to be valid XDR") + .max(100_000, "signedTxXdr exceeds maximum allowed length"), +}); diff --git a/backend/src/schemas/upload.ts b/backend/src/schemas/upload.ts new file mode 100644 index 0000000..2744df3 --- /dev/null +++ b/backend/src/schemas/upload.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const uploadMilestoneBody = z.object({ + escrow_id: z.string().min(1).max(200).default("unknown"), + milestone_index: z.coerce.number().int().min(0).default(0), +}); From c1018ce55e3c086cfbf07223cc5741eba0b26cbc Mon Sep 17 00:00:00 2001 From: stephanieoghenemega-eng Date: Thu, 18 Jun 2026 01:25:49 +0100 Subject: [PATCH 20/20] test(backend): add vitest validation tests for all route schemas --- backend/src/__tests__/validation.test.ts | 264 +++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 backend/src/__tests__/validation.test.ts diff --git a/backend/src/__tests__/validation.test.ts b/backend/src/__tests__/validation.test.ts new file mode 100644 index 0000000..0b67bcc --- /dev/null +++ b/backend/src/__tests__/validation.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect } from "vitest"; +import { postMessageBody, getConversationQuery, getInboxQuery } from "../schemas/messages.js"; +import { + getNotificationsQuery, + postNotificationBody, + patchNotificationReadParams, +} from "../schemas/notifications.js"; +import { postMilestonesBody, postCoverLetterBody, postRewriteBody } from "../schemas/ai.js"; +import { postGaslessApplyBody } from "../schemas/gasless.js"; +import { uploadMilestoneBody } from "../schemas/upload.js"; + +const VALID_ADDR_A = "G" + "A".repeat(55); +const VALID_ADDR_B = "G" + "B".repeat(55); +const VALID_UUID = "123e4567-e89b-12d3-a456-426614174000"; + +// ── messages ───────────────────────────────────────────────────────────────── + +describe("postMessageBody", () => { + it("accepts valid payload", () => { + const result = postMessageBody.safeParse({ + sender_address: VALID_ADDR_A, + recipient_address: VALID_ADDR_B, + content: "hello", + }); + expect(result.success).toBe(true); + }); + + it("rejects missing content", () => { + const result = postMessageBody.safeParse({ + sender_address: VALID_ADDR_A, + recipient_address: VALID_ADDR_B, + content: "", + }); + expect(result.success).toBe(false); + }); + + it("rejects self-messaging", () => { + const result = postMessageBody.safeParse({ + sender_address: VALID_ADDR_A, + recipient_address: VALID_ADDR_A, + content: "hi", + }); + expect(result.success).toBe(false); + }); + + it("rejects invalid Stellar address format", () => { + const result = postMessageBody.safeParse({ + sender_address: "notastellaraddress", + recipient_address: VALID_ADDR_B, + content: "hello", + }); + expect(result.success).toBe(false); + }); + + it("rejects content exceeding 4000 chars", () => { + const result = postMessageBody.safeParse({ + sender_address: VALID_ADDR_A, + recipient_address: VALID_ADDR_B, + content: "x".repeat(4001), + }); + expect(result.success).toBe(false); + }); +}); + +describe("getConversationQuery", () => { + it("accepts valid a, b addresses", () => { + const result = getConversationQuery.safeParse({ a: VALID_ADDR_A, b: VALID_ADDR_B }); + expect(result.success).toBe(true); + }); + + it("rejects invalid address for a", () => { + const result = getConversationQuery.safeParse({ a: "bad", b: VALID_ADDR_B }); + expect(result.success).toBe(false); + }); + + it("accepts optional since as ISO datetime", () => { + const result = getConversationQuery.safeParse({ + a: VALID_ADDR_A, + b: VALID_ADDR_B, + since: "2024-01-01T00:00:00.000Z", + }); + expect(result.success).toBe(true); + }); + + it("rejects since as non-datetime string", () => { + const result = getConversationQuery.safeParse({ + a: VALID_ADDR_A, + b: VALID_ADDR_B, + since: "not-a-date", + }); + expect(result.success).toBe(false); + }); +}); + +describe("getInboxQuery", () => { + it("accepts valid wallet", () => { + expect(getInboxQuery.safeParse({ wallet: VALID_ADDR_A }).success).toBe(true); + }); + + it("rejects wallet starting with wrong character", () => { + expect(getInboxQuery.safeParse({ wallet: "XABC" + "D".repeat(52) }).success).toBe(false); + }); + + it("rejects missing wallet", () => { + expect(getInboxQuery.safeParse({}).success).toBe(false); + }); +}); + +// ── notifications ───────────────────────────────────────────────────────────── + +describe("getNotificationsQuery", () => { + it("accepts valid 56-char G-address", () => { + expect(getNotificationsQuery.safeParse({ wallet: VALID_ADDR_A }).success).toBe(true); + }); + + it("rejects wallet that only starts with G but is too short", () => { + expect(getNotificationsQuery.safeParse({ wallet: "GABC" }).success).toBe(false); + }); +}); + +describe("postNotificationBody", () => { + it("accepts valid payload", () => { + const result = postNotificationBody.safeParse({ + wallet_address: VALID_ADDR_A, + type: "payment", + title: "Payment received", + message: "You received 100 XLM", + }); + expect(result.success).toBe(true); + }); + + it("rejects missing type", () => { + const result = postNotificationBody.safeParse({ + wallet_address: VALID_ADDR_A, + title: "hello", + message: "world", + }); + expect(result.success).toBe(false); + }); + + it("rejects title exceeding 200 chars", () => { + const result = postNotificationBody.safeParse({ + wallet_address: VALID_ADDR_A, + type: "t", + title: "x".repeat(201), + message: "msg", + }); + expect(result.success).toBe(false); + }); + + it("rejects invalid action_url", () => { + const result = postNotificationBody.safeParse({ + wallet_address: VALID_ADDR_A, + type: "t", + title: "t", + message: "m", + action_url: "not-a-url", + }); + expect(result.success).toBe(false); + }); +}); + +describe("patchNotificationReadParams", () => { + it("accepts valid UUID", () => { + expect(patchNotificationReadParams.safeParse({ id: VALID_UUID }).success).toBe(true); + }); + + it("rejects non-UUID string", () => { + expect(patchNotificationReadParams.safeParse({ id: "not-a-uuid" }).success).toBe(false); + }); +}); + +// ── AI ──────────────────────────────────────────────────────────────────────── + +describe("postMilestonesBody", () => { + it("accepts valid payload with userPrompt", () => { + const result = postMilestonesBody.safeParse({ userPrompt: "build a login page" }); + expect(result.success).toBe(true); + }); + + it("rejects missing userPrompt", () => { + expect(postMilestonesBody.safeParse({}).success).toBe(false); + }); + + it("rejects userPrompt over 2000 chars", () => { + expect(postMilestonesBody.safeParse({ userPrompt: "x".repeat(2001) }).success).toBe(false); + }); + + it("applies default empty strings for optional fields", () => { + const result = postMilestonesBody.safeParse({ userPrompt: "test" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.projectTitle).toBe(""); + expect(result.data.milestoneIndex).toBeNull(); + } + }); +}); + +describe("postCoverLetterBody", () => { + it("accepts valid payload", () => { + const result = postCoverLetterBody.safeParse({ jobDescription: "Build a REST API" }); + expect(result.success).toBe(true); + }); + + it("rejects missing jobDescription", () => { + expect(postCoverLetterBody.safeParse({ jobTitle: "Dev" }).success).toBe(false); + }); +}); + +describe("postRewriteBody", () => { + it("accepts valid text", () => { + expect(postRewriteBody.safeParse({ text: "Some project description" }).success).toBe(true); + }); + + it("rejects empty text", () => { + expect(postRewriteBody.safeParse({ text: "" }).success).toBe(false); + }); + + it("rejects text over 5000 chars", () => { + expect(postRewriteBody.safeParse({ text: "x".repeat(5001) }).success).toBe(false); + }); +}); + +// ── gasless ─────────────────────────────────────────────────────────────────── + +describe("postGaslessApplyBody", () => { + it("accepts valid XDR string", () => { + const result = postGaslessApplyBody.safeParse({ signedTxXdr: "A".repeat(100) }); + expect(result.success).toBe(true); + }); + + it("rejects signedTxXdr shorter than 48 chars", () => { + expect(postGaslessApplyBody.safeParse({ signedTxXdr: "short" }).success).toBe(false); + }); + + it("rejects missing signedTxXdr", () => { + expect(postGaslessApplyBody.safeParse({}).success).toBe(false); + }); +}); + +// ── upload ──────────────────────────────────────────────────────────────────── + +describe("uploadMilestoneBody", () => { + it("accepts valid escrow_id and milestone_index", () => { + const result = uploadMilestoneBody.safeParse({ escrow_id: "esc-123", milestone_index: "2" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.milestone_index).toBe(2); + } + }); + + it("applies defaults when fields are missing", () => { + const result = uploadMilestoneBody.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.escrow_id).toBe("unknown"); + expect(result.data.milestone_index).toBe(0); + } + }); + + it("rejects negative milestone_index", () => { + expect(uploadMilestoneBody.safeParse({ milestone_index: "-1" }).success).toBe(false); + }); +});