Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
028345c
chore(backend): install helmet, express-rate-limit, zod, vitest, supe…
stephanieoghenemega-eng Jun 18, 2026
494836b
feat(backend): add helmet security-headers middleware
stephanieoghenemega-eng Jun 18, 2026
d390ca9
feat(backend): add global rate limiter — 100 req / 15 min per IP
stephanieoghenemega-eng Jun 18, 2026
0fa8b0a
feat(backend): add validateBody/validateQuery Zod middleware helpers
stephanieoghenemega-eng Jun 18, 2026
9d5bada
feat(backend): add shared internalError/serviceUnavailable response h…
stephanieoghenemega-eng Jun 18, 2026
6cad3af
feat(backend/schemas): add Zod schemas for all notifications routes
stephanieoghenemega-eng Jun 18, 2026
22b1e2c
feat(backend/notifications): apply Zod validation to GET /notifications
stephanieoghenemega-eng Jun 18, 2026
ceeed5a
feat(backend/schemas): add Zod schemas for all messages routes
stephanieoghenemega-eng Jun 18, 2026
a9a93d6
feat(backend/messages): replace manual checks with Zod validation on …
stephanieoghenemega-eng Jun 18, 2026
ee15d15
feat(backend/messages): apply Zod validation to GET /conversation
stephanieoghenemega-eng Jun 18, 2026
ed183ac
feat(backend/messages): apply Zod validation to GET /inbox
stephanieoghenemega-eng Jun 18, 2026
f5867e5
feat(backend/messages): apply Zod validation to GET /unread-count
stephanieoghenemega-eng Jun 18, 2026
50b9db4
feat(backend/messages): apply Zod validation to PATCH /conversation/read
stephanieoghenemega-eng Jun 18, 2026
c11f53c
feat(backend/messages): apply Zod validation to PATCH /:id/read; sani…
stephanieoghenemega-eng Jun 18, 2026
b2ef454
feat(backend/schemas): add Zod schemas for all AI route bodies
stephanieoghenemega-eng Jun 18, 2026
df437ef
feat(backend/ai): apply Zod validation to POST /milestones
stephanieoghenemega-eng Jun 18, 2026
061bdd4
feat(backend/ai): apply Zod validation to POST /cover-letter
stephanieoghenemega-eng Jun 18, 2026
3ed169b
feat(backend/ai): apply Zod validation to POST /rewrite; sanitize Gro…
stephanieoghenemega-eng Jun 18, 2026
c6039d3
feat(backend): add Zod schemas and validation for gasless and upload …
stephanieoghenemega-eng Jun 18, 2026
c1018ce
test(backend): add vitest validation tests for all route schemas
stephanieoghenemega-eng Jun 18, 2026
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
14 changes: 11 additions & 3 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
264 changes: 264 additions & 0 deletions backend/src/__tests__/validation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
4 changes: 4 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import "dotenv/config";
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";
Expand All @@ -12,6 +14,8 @@ const app = express();
const port = Number(process.env.PORT) || 8787;
const apiSecret = process.env.API_SECRET;

app.use(helmet());
app.use(rateLimiter);
app.use(
cors({
origin: true,
Expand Down
9 changes: 9 additions & 0 deletions backend/src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -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` });
}
9 changes: 9 additions & 0 deletions backend/src/middleware/rateLimiter.ts
Original file line number Diff line number Diff line change
@@ -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" },
});
35 changes: 35 additions & 0 deletions backend/src/middleware/validate.ts
Original file line number Diff line number Diff line change
@@ -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();
};
}
Loading