Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
85 changes: 85 additions & 0 deletions src/middleware/rate-limit-wallet.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { NextFunction, Request, Response } from "express";
import type { AuthenticatedRequest } from "../types/auth";
import { HttpError } from "../utils/http-error";

interface WalletRateLimitEntry {
count: number;
windowStart: number;
}

interface WalletRateLimitConfig {
windowMs: number;
maxRequests: number;
}

const stores = new Map<string, Map<string, WalletRateLimitEntry>>();

function getStore(name: string): Map<string, WalletRateLimitEntry> {
let store = stores.get(name);
if (!store) {
store = new Map();
stores.set(name, store);
}
return store;
}

function getWalletAddress(req: Request): string | null {
const authReq = req as AuthenticatedRequest;
return authReq.user?.stellarAddress ?? null;
}

export function createWalletRateLimiter(config: WalletRateLimitConfig, name: string) {
const store = getStore(name);

// Periodically clean up stale entries
setInterval(() => {
const now = Date.now();
for (const [key, entry] of store) {
if (now - entry.windowStart >= config.windowMs) {
store.delete(key);
}
}
}, config.windowMs).unref();

return (req: Request, res: Response, next: NextFunction): void => {
const wallet = getWalletAddress(req);

if (!wallet) {
next(new HttpError(401, "Authentication required for rate-limited endpoint."));
return;
}

const now = Date.now();
const entry = store.get(wallet);

if (!entry || now - entry.windowStart >= config.windowMs) {
// Start a new window
store.set(wallet, { count: 1, windowStart: now });
next();
return;
}

if (entry.count >= config.maxRequests) {
const retryAfterMs = config.windowMs - (now - entry.windowStart);
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);

res.setHeader("Retry-After", String(retryAfterSeconds));
res.status(429).json({
success: false,
error: {
code: "RATE_LIMIT_EXCEEDED",
message: `Too many requests. Please wait ${retryAfterSeconds} seconds before retrying.`,
},
});
return;
}

entry.count++;
next();
};
}

// For testing: allow resetting stores
export function resetRateLimitStores(): void {
stores.clear();
}
9 changes: 8 additions & 1 deletion src/routes/investment.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ import { Router } from "express";
import { InvestmentController } from "../controllers/investment.controller";
import { InvestmentService } from "../services/investment.service";
import { createAuthMiddleware } from "../middleware/auth.middleware";
import { createWalletRateLimiter } from "../middleware/rate-limit-wallet.middleware";
import type { AuthService } from "../services/auth.service";

export interface InvestmentRouterDependencies {
investmentService: InvestmentService;
authService: AuthService;
}

// Per-wallet rate limit: max 10 investment submissions per 60 seconds
const investmentRateLimiter = createWalletRateLimiter(
{ windowMs: 60_000, maxRequests: 10 },
"investment-create",
);

export function createInvestmentRouter({
investmentService,
authService,
Expand All @@ -18,7 +25,7 @@ export function createInvestmentRouter({
const authMiddleware = createAuthMiddleware(authService);

// POST /api/v1/investments - Create a new investment commitment
router.post("/", authMiddleware, controller.createInvestment);
router.post("/", authMiddleware, investmentRateLimiter, controller.createInvestment);

// GET /api/v1/investments/dashboard - Investor portfolio aggregate
router.get("/dashboard", authMiddleware, controller.getDashboard);
Expand Down
8 changes: 8 additions & 0 deletions src/routes/invoice.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { InvoiceService } from "../services/invoice.service";
import type { AppConfig } from "../config/env";
import { createInvoiceController } from "../controllers/invoice.controller";
import { authenticateJWT, requireKYC } from "../middleware/auth.middleware";
import { createWalletRateLimiter } from "../middleware/rate-limit-wallet.middleware";
import { HttpError } from "../utils/http-error";

export interface InvoiceRouterDependencies {
Expand Down Expand Up @@ -154,6 +155,12 @@ export function createInvoiceRouter({

const kycGating = requireKYC(config.kyc.skipVerification);

// Per-wallet rate limit: max 5 invoice publishes per 60 seconds
const publishRateLimiter = createWalletRateLimiter(
{ windowMs: 60_000, maxRequests: 5 },
"invoice-publish",
);

// ============ INVOICE CRUD ENDPOINTS ============

// GET /api/v1/invoices - List invoices for authenticated seller
Expand Down Expand Up @@ -193,6 +200,7 @@ export function createInvoiceRouter({
"/:id/publish",
authenticateJWT,
kycGating,
publishRateLimiter,
controller.publishInvoice,
);

Expand Down
8 changes: 4 additions & 4 deletions src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export class AuthService {
throw new HttpError(401, "Invalid token payload.");
}

const user = await this.userRepository.findById(payload.sub);
const user = await this.userRepository.findByStellarAddress(payload.sub);

if (!user) {
throw new HttpError(401, "User no longer exists.");
Expand Down Expand Up @@ -225,14 +225,14 @@ export class AuthService {
this.config.jwt.secret,
{
...signOptions,
subject: user.id,
subject: user.stellarAddress,
},
);
}
}

class TypeOrmUserRepository implements UserRepositoryContract {
constructor(private readonly repository: Repository<User>) {}
constructor(private readonly repository: Repository<User>) { }

findById(id: string): Promise<User | null> {
return this.repository.findOne({
Expand All @@ -253,7 +253,7 @@ class TypeOrmUserRepository implements UserRepositoryContract {
}

class TypeOrmChallengeRepository implements ChallengeRepositoryContract {
constructor(private readonly repository: Repository<AuthChallenge>) {}
constructor(private readonly repository: Repository<AuthChallenge>) { }

async create(input: CreateChallengeRecordInput): Promise<ChallengeRecord> {
const entity = this.repository.create({
Expand Down
Loading
Loading