From 54c23d20ed89b088209230117a60c9312ba0300e Mon Sep 17 00:00:00 2001 From: Fayyo <94748999+Fayyo@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:42:39 +0000 Subject: [PATCH 1/3] perf: add database indexes for common queries --- package-lock.json | 1 + prisma/migrations/20260728000000_add_indexes/migration.sql | 5 +++++ prisma/schema.prisma | 2 ++ 3 files changed, 8 insertions(+) create mode 100644 prisma/migrations/20260728000000_add_indexes/migration.sql diff --git a/package-lock.json b/package-lock.json index abeded9..af1f10c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3211,6 +3211,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/prisma/migrations/20260728000000_add_indexes/migration.sql b/prisma/migrations/20260728000000_add_indexes/migration.sql new file mode 100644 index 0000000..8ade293 --- /dev/null +++ b/prisma/migrations/20260728000000_add_indexes/migration.sql @@ -0,0 +1,5 @@ +-- AddIndex +CREATE INDEX "expenses_payer_user_id_idx" ON "expenses"("payer_user_id"); + +-- AddIndex +CREATE INDEX "group_members_group_id_idx" ON "group_members"("group_id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 05709c9..4e7be64 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -63,6 +63,7 @@ model GroupMember { @@unique([groupId, userId]) @@index([userId]) + @@index([groupId]) @@map("group_members") } @@ -85,6 +86,7 @@ model Expense { shares ExpenseShare[] @@index([groupId]) + @@index([payerUserId]) @@map("expenses") } From d5052a5c2716bd10a8b248ce6b9e6bbc61433a69 Mon Sep 17 00:00:00 2001 From: Fayyo <94748999+Fayyo@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:55:39 +0000 Subject: [PATCH 2/3] feat: add rate limiting configuration to sensitive endpoints --- .env.example | 6 ++++ src/config.ts | 4 +++ src/routes/auth.ts | 3 +- src/routes/groups.ts | 2 +- src/routes/history.ts | 3 +- src/routes/settlements.ts | 6 ++-- tests/rate-limit.test.ts | 76 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 tests/rate-limit.test.ts diff --git a/.env.example b/.env.example index d9c1e96..9be71d7 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,9 @@ HORIZON_URL=https://horizon.stellar.org FEE_CACHE_TTL=30 MAX_FEE_STROOPS=1000 DEFAULT_FEE_STROOPS=100 + +# Per-route rate limits (requests per minute) +RATE_LIMIT_AUTH=5 +RATE_LIMIT_GROUP=20 +RATE_LIMIT_SETTLEMENT=20 +RATE_LIMIT_HISTORY=100 diff --git a/src/config.ts b/src/config.ts index c09eff4..5d47227 100644 --- a/src/config.ts +++ b/src/config.ts @@ -28,6 +28,10 @@ const schema = z.object({ UPLOADS_DIR: z.string().default("./uploads"), WORKER_INTERVAL_MS: z.coerce.number().positive().default(30000), NODE_ENV: z.string().default("development"), + RATE_LIMIT_AUTH: z.coerce.number().int().positive().default(5), + RATE_LIMIT_GROUP: z.coerce.number().int().positive().default(20), + RATE_LIMIT_SETTLEMENT: z.coerce.number().int().positive().default(20), + RATE_LIMIT_HISTORY: z.coerce.number().int().positive().default(100), }); const parsed = schema.parse(process.env); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 9789b91..ef94e3b 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -7,6 +7,7 @@ import { buildChallenge, verifyChallenge } from "../services/sep10"; import { signToken, requireUser } from "../plugins/auth"; import { serializeUser } from "../serializers"; import { audit } from "../services/audit"; +import { config } from "../config"; function shortName(pk: string): string { return `${pk.slice(0, 4)}…${pk.slice(-4)}`; @@ -15,7 +16,7 @@ function shortName(pk: string): string { export default async function authRoutes(app: FastifyInstance) { // Tighter rate limit on auth endpoints. const authLimit = { - config: { rateLimit: { max: 10, timeWindow: "1 minute" } }, + config: { rateLimit: { max: config.RATE_LIMIT_AUTH, timeWindow: "1 minute" } }, }; app.post( diff --git a/src/routes/groups.ts b/src/routes/groups.ts index 072345b..52d2dc0 100644 --- a/src/routes/groups.ts +++ b/src/routes/groups.ts @@ -22,7 +22,7 @@ export default async function groupRoutes(app: FastifyInstance) { app.addHook("preHandler", app.authenticate); // -- create ----------------------------------------------------------------- - app.post("/groups", async (req) => { + app.post("/groups", { config: { rateLimit: { max: config.RATE_LIMIT_GROUP, timeWindow: "1 minute" } } }, async (req) => { const auth = requireUser(req); const body = z .object({ diff --git a/src/routes/history.ts b/src/routes/history.ts index c1a5d0c..b9d332c 100644 --- a/src/routes/history.ts +++ b/src/routes/history.ts @@ -1,12 +1,13 @@ import { FastifyInstance } from "fastify"; import { prisma } from "../db"; +import { config } from "../config"; import { requireUser } from "../plugins/auth"; import { serializeExpense, serializeSettlement } from "../serializers"; export default async function historyRoutes(app: FastifyInstance) { app.addHook("preHandler", app.authenticate); - app.get("/history", async (req) => { + app.get("/history", { config: { rateLimit: { max: config.RATE_LIMIT_HISTORY, timeWindow: "1 minute" } } }, async (req) => { const auth = requireUser(req); const [expenses, settlements] = await Promise.all([ diff --git a/src/routes/settlements.ts b/src/routes/settlements.ts index 232d2f9..5bbb648 100644 --- a/src/routes/settlements.ts +++ b/src/routes/settlements.ts @@ -25,8 +25,10 @@ const settlementInclude = { from: true, to: true } as const; export default async function settlementRoutes(app: FastifyInstance) { app.addHook("preHandler", app.authenticate); + const settleLimit = { config: { rateLimit: { max: config.RATE_LIMIT_SETTLEMENT, timeWindow: "1 minute" } } }; + // -- settle a specific expense share ---------------------------------------- - app.post("/expenses/:id/settle", async (req) => { + app.post("/expenses/:id/settle", settleLimit, async (req) => { const auth = requireUser(req); const { id: expenseId } = z.object({ id: z.string() }).parse(req.params); const body = z @@ -96,7 +98,7 @@ export default async function settlementRoutes(app: FastifyInstance) { }); // -- freeform settle-up against net balance --------------------------------- - app.post("/groups/:id/settlements", async (req) => { + app.post("/groups/:id/settlements", settleLimit, async (req) => { const auth = requireUser(req); const { id: groupId } = z.object({ id: z.string() }).parse(req.params); await requireMembership(groupId, auth.id); diff --git a/tests/rate-limit.test.ts b/tests/rate-limit.test.ts new file mode 100644 index 0000000..17b0592 --- /dev/null +++ b/tests/rate-limit.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import Fastify from "fastify"; +import rateLimit from "@fastify/rate-limit"; + +type App = Awaited>; + +function buildLimitedApp(max: number, perRouteMax?: number) { + return async () => { + const app = Fastify(); + await app.register(rateLimit, { max, timeWindow: "1 minute" }); + app.get("/test-open", async () => ({ ok: true })); + if (perRouteMax !== undefined) { + app.post( + "/test-limited", + { config: { rateLimit: { max: perRouteMax, timeWindow: "1 minute" } } }, + async () => ({ ok: true }) + ); + } + return app; + }; +} + +describe("rate limiting - under limit", () => { + let app: App; + + beforeAll(async () => { + app = await buildLimitedApp(100, 2)(); + }); + afterAll(async () => { await app.close(); }); + + it("allows requests under the per-route limit and exposes headers", async () => { + const res = await app.inject({ method: "POST", url: "/test-limited" }); + expect(res.statusCode).toBe(200); + expect(res.headers["x-ratelimit-limit"]).toBe("2"); + expect(res.headers["x-ratelimit-remaining"]).toBe("1"); + }); +}); + +describe("rate limiting - exceeds limit", () => { + let app: App; + + beforeAll(async () => { + app = await buildLimitedApp(100, 2)(); + }); + afterAll(async () => { await app.close(); }); + + it("returns 429 with rate-limit headers after exceeding per-route limit", async () => { + await app.inject({ method: "POST", url: "/test-limited" }); + await app.inject({ method: "POST", url: "/test-limited" }); + + const r3 = await app.inject({ method: "POST", url: "/test-limited" }); + expect(r3.statusCode).toBe(429); + expect(r3.headers["retry-after"]).toBeTruthy(); + expect(r3.headers["x-ratelimit-limit"]).toBe("2"); + expect(r3.headers["x-ratelimit-remaining"]).toBe("0"); + }); +}); + +describe("rate limiting - global limit", () => { + let app: App; + + beforeAll(async () => { + app = await buildLimitedApp(2)(); + }); + afterAll(async () => { await app.close(); }); + + it("returns 429 and headers when global limit is exceeded", async () => { + await app.inject({ method: "GET", url: "/test-open" }); + await app.inject({ method: "GET", url: "/test-open" }); + + const r3 = await app.inject({ method: "GET", url: "/test-open" }); + expect(r3.statusCode).toBe(429); + expect(r3.headers["x-ratelimit-limit"]).toBe("2"); + expect(typeof r3.headers["x-ratelimit-remaining"]).toBe("string"); + }); +}); From 7dc0f464c67d6eecd8967af79508de44ff64f659 Mon Sep 17 00:00:00 2001 From: Fayyo <94748999+Fayyo@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:07:24 +0000 Subject: [PATCH 3/3] perf: add database indexes on frequently queried columns --- .../20260728000001_add_query_indexes/migration.sql | 11 +++++++++++ prisma/schema.prisma | 4 ++++ 2 files changed, 15 insertions(+) create mode 100644 prisma/migrations/20260728000001_add_query_indexes/migration.sql diff --git a/prisma/migrations/20260728000001_add_query_indexes/migration.sql b/prisma/migrations/20260728000001_add_query_indexes/migration.sql new file mode 100644 index 0000000..5088bc1 --- /dev/null +++ b/prisma/migrations/20260728000001_add_query_indexes/migration.sql @@ -0,0 +1,11 @@ +-- AddIndex +CREATE INDEX "expenses_group_id_created_at_idx" ON "expenses"("group_id", "created_at"); + +-- AddIndex +CREATE INDEX "settlements_created_at_idx" ON "settlements"("created_at"); + +-- AddIndex +CREATE INDEX "audit_logs_entity_type_entity_id_idx" ON "audit_logs"("entity_type", "entity_id"); + +-- AddIndex +CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs"("created_at"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4e7be64..84e0064 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -87,6 +87,7 @@ model Expense { @@index([groupId]) @@index([payerUserId]) + @@index([groupId, createdAt]) @@map("expenses") } @@ -135,6 +136,7 @@ model Settlement { @@index([fromUserId]) @@index([toUserId]) @@index([status]) + @@index([createdAt]) @@map("settlements") } @@ -235,5 +237,7 @@ model AuditLog { user User? @relation(fields: [userId], references: [id]) @@index([userId]) + @@index([entityType, entityId]) + @@index([createdAt]) @@map("audit_logs") }