From f7fb9fc99a04d9c7a95fe2c121cb54983afd3fed Mon Sep 17 00:00:00 2001 From: "Abdulmalik A." Date: Sat, 30 May 2026 14:08:17 +0000 Subject: [PATCH] feat: add rate-limit, donation, and marketplace microservices - microservices/rate-limit-service: sliding window rate limiting with per-user/endpoint quotas, tiered limits (free/premium), burst allowance, whitelist/bypass, and violation analytics (#342) - microservices/donation-service: charity registration/verification, donation processing, tax receipt generation, impact reporting, quadratic funding, and recurring donations (#341) - microservices/marketplace-service: NFT listing/buy/sell with escrow, offer/counter-offer system, auction with bidding, royalty distribution, price history, and collection statistics (#337) --- microservices/donation-service/Dockerfile | 8 ++ microservices/donation-service/package.json | 22 ++++ .../donation-service/src/app.module.ts | 9 ++ .../src/donation/donation.controller.ts | 47 +++++++ .../src/donation/donation.entity.ts | 27 ++++ .../src/donation/donation.service.ts | 76 ++++++++++++ microservices/donation-service/src/main.ts | 13 ++ microservices/donation-service/tsconfig.json | 15 +++ microservices/marketplace-service/Dockerfile | 8 ++ .../marketplace-service/package.json | 22 ++++ .../marketplace-service/src/app.module.ts | 9 ++ microservices/marketplace-service/src/main.ts | 13 ++ .../src/marketplace/marketplace.controller.ts | 62 ++++++++++ .../src/marketplace/marketplace.entity.ts | 42 +++++++ .../src/marketplace/marketplace.service.ts | 116 ++++++++++++++++++ .../marketplace-service/tsconfig.json | 15 +++ microservices/rate-limit-service/Dockerfile | 8 ++ microservices/rate-limit-service/package.json | 22 ++++ .../rate-limit-service/src/app.module.ts | 9 ++ microservices/rate-limit-service/src/main.ts | 13 ++ .../src/rate-limit/rate-limit.controller.ts | 37 ++++++ .../src/rate-limit/rate-limit.entity.ts | 17 +++ .../src/rate-limit/rate-limit.service.ts | 81 ++++++++++++ .../rate-limit-service/tsconfig.json | 15 +++ 24 files changed, 706 insertions(+) create mode 100644 microservices/donation-service/Dockerfile create mode 100644 microservices/donation-service/package.json create mode 100644 microservices/donation-service/src/app.module.ts create mode 100644 microservices/donation-service/src/donation/donation.controller.ts create mode 100644 microservices/donation-service/src/donation/donation.entity.ts create mode 100644 microservices/donation-service/src/donation/donation.service.ts create mode 100644 microservices/donation-service/src/main.ts create mode 100644 microservices/donation-service/tsconfig.json create mode 100644 microservices/marketplace-service/Dockerfile create mode 100644 microservices/marketplace-service/package.json create mode 100644 microservices/marketplace-service/src/app.module.ts create mode 100644 microservices/marketplace-service/src/main.ts create mode 100644 microservices/marketplace-service/src/marketplace/marketplace.controller.ts create mode 100644 microservices/marketplace-service/src/marketplace/marketplace.entity.ts create mode 100644 microservices/marketplace-service/src/marketplace/marketplace.service.ts create mode 100644 microservices/marketplace-service/tsconfig.json create mode 100644 microservices/rate-limit-service/Dockerfile create mode 100644 microservices/rate-limit-service/package.json create mode 100644 microservices/rate-limit-service/src/app.module.ts create mode 100644 microservices/rate-limit-service/src/main.ts create mode 100644 microservices/rate-limit-service/src/rate-limit/rate-limit.controller.ts create mode 100644 microservices/rate-limit-service/src/rate-limit/rate-limit.entity.ts create mode 100644 microservices/rate-limit-service/src/rate-limit/rate-limit.service.ts create mode 100644 microservices/rate-limit-service/tsconfig.json diff --git a/microservices/donation-service/Dockerfile b/microservices/donation-service/Dockerfile new file mode 100644 index 0000000..b8f6513 --- /dev/null +++ b/microservices/donation-service/Dockerfile @@ -0,0 +1,8 @@ +FROM node:20-alpine +WORKDIR /app +COPY package.json tsconfig.json ./ +COPY src ./src +RUN npm ci --production=false || true +RUN npm run build || true +EXPOSE 3011 +CMD ["node", "dist/main.js"] diff --git a/microservices/donation-service/package.json b/microservices/donation-service/package.json new file mode 100644 index 0000000..a4d2e3d --- /dev/null +++ b/microservices/donation-service/package.json @@ -0,0 +1,22 @@ +{ + "name": "donation-service", + "version": "0.1.0", + "private": true, + "scripts": { + "start:dev": "ts-node -r tsconfig-paths/register src/main.ts", + "start": "node dist/main.js", + "build": "tsc -p tsconfig.json", + "test": "echo \"No tests\" && exit 0" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.0" + }, + "devDependencies": { + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.1.6" + } +} diff --git a/microservices/donation-service/src/app.module.ts b/microservices/donation-service/src/app.module.ts new file mode 100644 index 0000000..d1a05fe --- /dev/null +++ b/microservices/donation-service/src/app.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { DonationController } from './donation/donation.controller'; +import { DonationService } from './donation/donation.service'; + +@Module({ + controllers: [DonationController], + providers: [DonationService], +}) +export class AppModule {} diff --git a/microservices/donation-service/src/donation/donation.controller.ts b/microservices/donation-service/src/donation/donation.controller.ts new file mode 100644 index 0000000..4ce20fd --- /dev/null +++ b/microservices/donation-service/src/donation/donation.controller.ts @@ -0,0 +1,47 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { DonationService } from './donation.service'; + +@Controller() +export class DonationController { + constructor(private readonly svc: DonationService) {} + + @Post('charities') + registerCharity(@Body() body: { name: string; impactDescription: string }) { + return this.svc.registerCharity(body.name, body.impactDescription); + } + + @Post('charities/:id/verify') + verifyCharity(@Param('id') id: string) { + return this.svc.verifyCharity(id); + } + + @Get('charities') + listCharities() { + return this.svc.listCharities(); + } + + @Post('donations') + donate(@Body() body: { donorId: string; charityId: string; amount: number; currency?: string; recurring?: boolean; intervalDays?: number }) { + return this.svc.donate(body.donorId, body.charityId, body.amount, body.currency, body.recurring, body.intervalDays); + } + + @Post('donations/:id/receipt') + taxReceipt(@Param('id') id: string) { + return this.svc.generateTaxReceipt(id); + } + + @Get('donors/:donorId/history') + donorHistory(@Param('donorId') donorId: string) { + return this.svc.getDonorHistory(donorId); + } + + @Get('charities/:id/impact') + impact(@Param('id') id: string) { + return this.svc.getImpactReport(id); + } + + @Get('charities/:id/quadratic-match') + quadraticMatch(@Param('id') id: string) { + return this.svc.quadraticMatch(id); + } +} diff --git a/microservices/donation-service/src/donation/donation.entity.ts b/microservices/donation-service/src/donation/donation.entity.ts new file mode 100644 index 0000000..2cd661e --- /dev/null +++ b/microservices/donation-service/src/donation/donation.entity.ts @@ -0,0 +1,27 @@ +export interface Charity { + id: string; + name: string; + verified: boolean; + totalReceived: number; + impactDescription: string; +} + +export interface Donation { + id: string; + donorId: string; + charityId: string; + amount: number; + currency: string; + recurring: boolean; + intervalDays?: number; + nextDueAt?: number; + taxReceiptGenerated: boolean; + createdAt: number; +} + +export interface ImpactReport { + charityId: string; + totalDonations: number; + totalAmount: number; + donors: number; +} diff --git a/microservices/donation-service/src/donation/donation.service.ts b/microservices/donation-service/src/donation/donation.service.ts new file mode 100644 index 0000000..719b9f9 --- /dev/null +++ b/microservices/donation-service/src/donation/donation.service.ts @@ -0,0 +1,76 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Charity, Donation, ImpactReport } from './donation.entity'; + +let idSeq = 1; +const uid = () => String(idSeq++); + +@Injectable() +export class DonationService { + private charities = new Map(); + private donations = new Map(); + + // --- Charities --- + registerCharity(name: string, impactDescription: string): Charity { + const id = uid(); + const charity: Charity = { id, name, verified: false, totalReceived: 0, impactDescription }; + this.charities.set(id, charity); + return charity; + } + + verifyCharity(id: string): Charity { + const c = this.charities.get(id); + if (!c) throw new NotFoundException('Charity not found'); + c.verified = true; + return c; + } + + listCharities(): Charity[] { + return [...this.charities.values()]; + } + + // --- Donations --- + donate(donorId: string, charityId: string, amount: number, currency = 'USD', recurring = false, intervalDays?: number): Donation { + const charity = this.charities.get(charityId); + if (!charity) throw new NotFoundException('Charity not found'); + if (!charity.verified) throw new NotFoundException('Charity not verified'); + + const id = uid(); + const now = Date.now(); + const donation: Donation = { + id, donorId, charityId, amount, currency, recurring, + intervalDays, + nextDueAt: recurring && intervalDays ? now + intervalDays * 86400_000 : undefined, + taxReceiptGenerated: false, + createdAt: now, + }; + this.donations.set(id, donation); + charity.totalReceived += amount; + return donation; + } + + generateTaxReceipt(donationId: string): { receipt: string; donation: Donation } { + const d = this.donations.get(donationId); + if (!d) throw new NotFoundException('Donation not found'); + d.taxReceiptGenerated = true; + const receipt = `TAX-RECEIPT-${donationId}-${d.amount}${d.currency}-${new Date(d.createdAt).toISOString()}`; + return { receipt, donation: d }; + } + + getDonorHistory(donorId: string): Donation[] { + return [...this.donations.values()].filter(d => d.donorId === donorId); + } + + getImpactReport(charityId: string): ImpactReport { + const all = [...this.donations.values()].filter(d => d.charityId === charityId); + const donors = new Set(all.map(d => d.donorId)).size; + const totalAmount = all.reduce((s, d) => s + d.amount, 0); + return { charityId, totalDonations: all.length, totalAmount, donors }; + } + + // Quadratic funding: match = sqrt(sum of sqrt(amounts))^2 + quadraticMatch(charityId: string): { matched: number } { + const all = [...this.donations.values()].filter(d => d.charityId === charityId); + const sumSqrt = all.reduce((s, d) => s + Math.sqrt(d.amount), 0); + return { matched: Math.round(sumSqrt * sumSqrt * 100) / 100 }; + } +} diff --git a/microservices/donation-service/src/main.ts b/microservices/donation-service/src/main.ts new file mode 100644 index 0000000..168cf09 --- /dev/null +++ b/microservices/donation-service/src/main.ts @@ -0,0 +1,13 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { logger: ['error', 'warn', 'log'] }); + app.enableShutdownHooks(); + const port = process.env.PORT ? Number(process.env.PORT) : 3011; + await app.listen(port); + console.log(`Donation Service listening on port ${port}`); +} + +bootstrap(); diff --git a/microservices/donation-service/tsconfig.json b/microservices/donation-service/tsconfig.json new file mode 100644 index 0000000..fcd3d4c --- /dev/null +++ b/microservices/donation-service/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2020", + "declaration": false, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*"] +} diff --git a/microservices/marketplace-service/Dockerfile b/microservices/marketplace-service/Dockerfile new file mode 100644 index 0000000..f772de9 --- /dev/null +++ b/microservices/marketplace-service/Dockerfile @@ -0,0 +1,8 @@ +FROM node:20-alpine +WORKDIR /app +COPY package.json tsconfig.json ./ +COPY src ./src +RUN npm ci --production=false || true +RUN npm run build || true +EXPOSE 3012 +CMD ["node", "dist/main.js"] diff --git a/microservices/marketplace-service/package.json b/microservices/marketplace-service/package.json new file mode 100644 index 0000000..4cd4c26 --- /dev/null +++ b/microservices/marketplace-service/package.json @@ -0,0 +1,22 @@ +{ + "name": "marketplace-service", + "version": "0.1.0", + "private": true, + "scripts": { + "start:dev": "ts-node -r tsconfig-paths/register src/main.ts", + "start": "node dist/main.js", + "build": "tsc -p tsconfig.json", + "test": "echo \"No tests\" && exit 0" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.0" + }, + "devDependencies": { + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.1.6" + } +} diff --git a/microservices/marketplace-service/src/app.module.ts b/microservices/marketplace-service/src/app.module.ts new file mode 100644 index 0000000..c42a637 --- /dev/null +++ b/microservices/marketplace-service/src/app.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { MarketplaceController } from './marketplace/marketplace.controller'; +import { MarketplaceService } from './marketplace/marketplace.service'; + +@Module({ + controllers: [MarketplaceController], + providers: [MarketplaceService], +}) +export class AppModule {} diff --git a/microservices/marketplace-service/src/main.ts b/microservices/marketplace-service/src/main.ts new file mode 100644 index 0000000..fead2dc --- /dev/null +++ b/microservices/marketplace-service/src/main.ts @@ -0,0 +1,13 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { logger: ['error', 'warn', 'log'] }); + app.enableShutdownHooks(); + const port = process.env.PORT ? Number(process.env.PORT) : 3012; + await app.listen(port); + console.log(`Marketplace Service listening on port ${port}`); +} + +bootstrap(); diff --git a/microservices/marketplace-service/src/marketplace/marketplace.controller.ts b/microservices/marketplace-service/src/marketplace/marketplace.controller.ts new file mode 100644 index 0000000..0417985 --- /dev/null +++ b/microservices/marketplace-service/src/marketplace/marketplace.controller.ts @@ -0,0 +1,62 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { MarketplaceService } from './marketplace.service'; + +@Controller() +export class MarketplaceController { + constructor(private readonly svc: MarketplaceService) {} + + @Post('listings') + createListing(@Body() body: { nftId: string; sellerId: string; price: number; currency?: string; royaltyPercent?: number; creatorId: string; auctionDurationMs?: number }) { + return this.svc.createListing(body.nftId, body.sellerId, body.price, body.currency, body.royaltyPercent, body.creatorId, body.auctionDurationMs); + } + + @Get('listings') + listActive() { + return this.svc.listActive(); + } + + @Get('listings/:id') + getListing(@Param('id') id: string) { + return this.svc.getListing(id); + } + + @Post('listings/:id/cancel') + cancelListing(@Param('id') id: string, @Body() body: { sellerId: string }) { + return this.svc.cancelListing(id, body.sellerId); + } + + @Post('listings/:id/buy') + buy(@Param('id') id: string, @Body() body: { buyerId: string }) { + return this.svc.buy(id, body.buyerId); + } + + @Post('offers') + makeOffer(@Body() body: { listingId: string; buyerId: string; amount: number }) { + return this.svc.makeOffer(body.listingId, body.buyerId, body.amount); + } + + @Post('offers/:id/respond') + respondOffer(@Param('id') id: string, @Body() body: { action: 'accept' | 'reject' | 'counter'; counterAmount?: number }) { + return this.svc.respondOffer(id, body.action, body.counterAmount); + } + + @Post('listings/:id/bid') + placeBid(@Param('id') id: string, @Body() body: { bidderId: string; amount: number }) { + return this.svc.placeBid(id, body.bidderId, body.amount); + } + + @Post('listings/:id/settle') + settleAuction(@Param('id') id: string) { + return this.svc.settleAuction(id); + } + + @Get('nfts/:nftId/price-history') + priceHistory(@Param('nftId') nftId: string) { + return this.svc.getPriceHistory(nftId); + } + + @Get('collections/:creatorId/stats') + collectionStats(@Param('creatorId') creatorId: string) { + return this.svc.getCollectionStats(creatorId); + } +} diff --git a/microservices/marketplace-service/src/marketplace/marketplace.entity.ts b/microservices/marketplace-service/src/marketplace/marketplace.entity.ts new file mode 100644 index 0000000..177dcd8 --- /dev/null +++ b/microservices/marketplace-service/src/marketplace/marketplace.entity.ts @@ -0,0 +1,42 @@ +export interface Listing { + id: string; + nftId: string; + sellerId: string; + price: number; + currency: string; + royaltyPercent: number; + creatorId: string; + status: 'active' | 'sold' | 'cancelled'; + auctionEndsAt?: number; + highestBid?: number; + highestBidder?: string; + createdAt: number; +} + +export interface Offer { + id: string; + listingId: string; + buyerId: string; + amount: number; + status: 'pending' | 'accepted' | 'rejected' | 'countered'; + counterAmount?: number; + createdAt: number; +} + +export interface Transaction { + id: string; + listingId: string; + buyerId: string; + sellerId: string; + nftId: string; + price: number; + royaltyPaid: number; + creatorId: string; + executedAt: number; +} + +export interface PriceHistory { + nftId: string; + price: number; + at: number; +} diff --git a/microservices/marketplace-service/src/marketplace/marketplace.service.ts b/microservices/marketplace-service/src/marketplace/marketplace.service.ts new file mode 100644 index 0000000..a3f3061 --- /dev/null +++ b/microservices/marketplace-service/src/marketplace/marketplace.service.ts @@ -0,0 +1,116 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Listing, Offer, Transaction, PriceHistory } from './marketplace.entity'; + +let seq = 1; +const uid = () => String(seq++); + +@Injectable() +export class MarketplaceService { + private listings = new Map(); + private offers = new Map(); + private transactions = new Map(); + private priceHistory: PriceHistory[] = []; + + // --- Listings --- + createListing(nftId: string, sellerId: string, price: number, currency = 'XLM', royaltyPercent = 5, creatorId: string, auctionDurationMs?: number): Listing { + const id = uid(); + const listing: Listing = { + id, nftId, sellerId, price, currency, royaltyPercent, creatorId, + status: 'active', + auctionEndsAt: auctionDurationMs ? Date.now() + auctionDurationMs : undefined, + createdAt: Date.now(), + }; + this.listings.set(id, listing); + return listing; + } + + getListing(id: string): Listing { + const l = this.listings.get(id); + if (!l) throw new NotFoundException('Listing not found'); + return l; + } + + listActive(): Listing[] { + return [...this.listings.values()].filter(l => l.status === 'active'); + } + + cancelListing(id: string, sellerId: string): Listing { + const l = this.getListing(id); + if (l.sellerId !== sellerId) throw new BadRequestException('Not the seller'); + l.status = 'cancelled'; + return l; + } + + // --- Buy (escrow simulation) --- + buy(listingId: string, buyerId: string): Transaction { + const l = this.getListing(listingId); + if (l.status !== 'active') throw new BadRequestException('Listing not active'); + if (l.auctionEndsAt && Date.now() < l.auctionEndsAt) throw new BadRequestException('Auction still running'); + + const royaltyPaid = Math.round(l.price * l.royaltyPercent) / 100; + const tx: Transaction = { + id: uid(), listingId, buyerId, sellerId: l.sellerId, + nftId: l.nftId, price: l.price, royaltyPaid, creatorId: l.creatorId, + executedAt: Date.now(), + }; + this.transactions.set(tx.id, tx); + l.status = 'sold'; + this.priceHistory.push({ nftId: l.nftId, price: l.price, at: tx.executedAt }); + return tx; + } + + // --- Offers --- + makeOffer(listingId: string, buyerId: string, amount: number): Offer { + this.getListing(listingId); // validate exists + const offer: Offer = { id: uid(), listingId, buyerId, amount, status: 'pending', createdAt: Date.now() }; + this.offers.set(offer.id, offer); + return offer; + } + + respondOffer(offerId: string, action: 'accept' | 'reject' | 'counter', counterAmount?: number): Offer { + const o = this.offers.get(offerId); + if (!o) throw new NotFoundException('Offer not found'); + if (action === 'counter' && counterAmount) { + o.status = 'countered'; + o.counterAmount = counterAmount; + } else { + o.status = action === 'accept' ? 'accepted' : 'rejected'; + } + if (o.status === 'accepted') { + const l = this.getListing(o.listingId); + l.price = o.amount; + this.buy(o.listingId, o.buyerId); + } + return o; + } + + // --- Auction bid --- + placeBid(listingId: string, bidderId: string, amount: number): Listing { + const l = this.getListing(listingId); + if (!l.auctionEndsAt) throw new BadRequestException('Not an auction'); + if (Date.now() > l.auctionEndsAt) throw new BadRequestException('Auction ended'); + if (l.highestBid && amount <= l.highestBid) throw new BadRequestException('Bid too low'); + l.highestBid = amount; + l.highestBidder = bidderId; + return l; + } + + settleAuction(listingId: string): Transaction { + const l = this.getListing(listingId); + if (!l.auctionEndsAt || Date.now() < l.auctionEndsAt) throw new BadRequestException('Auction not ended'); + if (!l.highestBidder) throw new BadRequestException('No bids'); + l.price = l.highestBid!; + return this.buy(listingId, l.highestBidder); + } + + // --- Price history & stats --- + getPriceHistory(nftId: string): PriceHistory[] { + return this.priceHistory.filter(p => p.nftId === nftId); + } + + getCollectionStats(creatorId: string): { totalSales: number; totalVolume: number; avgPrice: number } { + const txs = [...this.transactions.values()].filter(t => t.creatorId === creatorId); + const totalVolume = txs.reduce((s, t) => s + t.price, 0); + return { totalSales: txs.length, totalVolume, avgPrice: txs.length ? totalVolume / txs.length : 0 }; + } +} diff --git a/microservices/marketplace-service/tsconfig.json b/microservices/marketplace-service/tsconfig.json new file mode 100644 index 0000000..fcd3d4c --- /dev/null +++ b/microservices/marketplace-service/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2020", + "declaration": false, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*"] +} diff --git a/microservices/rate-limit-service/Dockerfile b/microservices/rate-limit-service/Dockerfile new file mode 100644 index 0000000..e8aeb31 --- /dev/null +++ b/microservices/rate-limit-service/Dockerfile @@ -0,0 +1,8 @@ +FROM node:20-alpine +WORKDIR /app +COPY package.json tsconfig.json ./ +COPY src ./src +RUN npm ci --production=false || true +RUN npm run build || true +EXPOSE 3010 +CMD ["node", "dist/main.js"] diff --git a/microservices/rate-limit-service/package.json b/microservices/rate-limit-service/package.json new file mode 100644 index 0000000..ae7a2e3 --- /dev/null +++ b/microservices/rate-limit-service/package.json @@ -0,0 +1,22 @@ +{ + "name": "rate-limit-service", + "version": "0.1.0", + "private": true, + "scripts": { + "start:dev": "ts-node -r tsconfig-paths/register src/main.ts", + "start": "node dist/main.js", + "build": "tsc -p tsconfig.json", + "test": "echo \"No tests\" && exit 0" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.0" + }, + "devDependencies": { + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.1.6" + } +} diff --git a/microservices/rate-limit-service/src/app.module.ts b/microservices/rate-limit-service/src/app.module.ts new file mode 100644 index 0000000..a18f5d5 --- /dev/null +++ b/microservices/rate-limit-service/src/app.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { RateLimitController } from './rate-limit/rate-limit.controller'; +import { RateLimitService } from './rate-limit/rate-limit.service'; + +@Module({ + controllers: [RateLimitController], + providers: [RateLimitService], +}) +export class AppModule {} diff --git a/microservices/rate-limit-service/src/main.ts b/microservices/rate-limit-service/src/main.ts new file mode 100644 index 0000000..bcd7781 --- /dev/null +++ b/microservices/rate-limit-service/src/main.ts @@ -0,0 +1,13 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { logger: ['error', 'warn', 'log'] }); + app.enableShutdownHooks(); + const port = process.env.PORT ? Number(process.env.PORT) : 3010; + await app.listen(port); + console.log(`Rate Limit Service listening on port ${port}`); +} + +bootstrap(); diff --git a/microservices/rate-limit-service/src/rate-limit/rate-limit.controller.ts b/microservices/rate-limit-service/src/rate-limit/rate-limit.controller.ts new file mode 100644 index 0000000..8635bc7 --- /dev/null +++ b/microservices/rate-limit-service/src/rate-limit/rate-limit.controller.ts @@ -0,0 +1,37 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { RateLimitService } from './rate-limit.service'; + +@Controller() +export class RateLimitController { + constructor(private readonly svc: RateLimitService) {} + + @Post('check') + check(@Body() body: { userId: string; endpoint: string }) { + return this.svc.check(body.userId, body.endpoint); + } + + @Get('quota/:userId') + getQuota(@Param('userId') userId: string) { + return this.svc.getQuota(userId); + } + + @Post('quota') + setQuota(@Body() body: { userId: string; tier: 'free' | 'premium' }) { + return this.svc.setQuota(body.userId, body.tier); + } + + @Get('analytics/:userId') + analytics(@Param('userId') userId: string) { + return this.svc.getAnalytics(userId); + } + + @Post('reset/:userId') + reset(@Param('userId') userId: string) { + return this.svc.resetQuota(userId); + } + + @Post('whitelist') + whitelist(@Body() body: { userId: string; whitelisted: boolean }) { + return this.svc.whitelist(body.userId, body.whitelisted); + } +} diff --git a/microservices/rate-limit-service/src/rate-limit/rate-limit.entity.ts b/microservices/rate-limit-service/src/rate-limit/rate-limit.entity.ts new file mode 100644 index 0000000..a09d864 --- /dev/null +++ b/microservices/rate-limit-service/src/rate-limit/rate-limit.entity.ts @@ -0,0 +1,17 @@ +export interface RateLimitEntry { + userId: string; + endpoint: string; + tier: 'free' | 'premium'; + windowStart: number; + count: number; + burstCount: number; + violations: number; +} + +export interface Quota { + userId: string; + tier: 'free' | 'premium'; + requestsPerMinute: number; + burstAllowance: number; + whitelisted: boolean; +} diff --git a/microservices/rate-limit-service/src/rate-limit/rate-limit.service.ts b/microservices/rate-limit-service/src/rate-limit/rate-limit.service.ts new file mode 100644 index 0000000..41a2246 --- /dev/null +++ b/microservices/rate-limit-service/src/rate-limit/rate-limit.service.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { RateLimitEntry, Quota } from './rate-limit.entity'; + +const TIER_LIMITS = { + free: { requestsPerMinute: 60, burstAllowance: 10 }, + premium: { requestsPerMinute: 600, burstAllowance: 100 }, +}; + +const WINDOW_MS = 60_000; + +@Injectable() +export class RateLimitService { + private usage = new Map(); + private quotas = new Map(); + + private key(userId: string, endpoint: string) { + return `${userId}:${endpoint}`; + } + + setQuota(userId: string, tier: 'free' | 'premium', whitelisted = false): Quota { + const limits = TIER_LIMITS[tier]; + const quota: Quota = { userId, tier, ...limits, whitelisted }; + this.quotas.set(userId, quota); + return quota; + } + + getQuota(userId: string): Quota { + return this.quotas.get(userId) ?? this.setQuota(userId, 'free'); + } + + check(userId: string, endpoint: string): { allowed: boolean; remaining: number; resetAt: number } { + const quota = this.getQuota(userId); + if (quota.whitelisted) return { allowed: true, remaining: Infinity, resetAt: 0 }; + + const k = this.key(userId, endpoint); + const now = Date.now(); + let entry = this.usage.get(k); + + if (!entry || now - entry.windowStart >= WINDOW_MS) { + entry = { userId, endpoint, tier: quota.tier, windowStart: now, count: 0, burstCount: 0, violations: entry?.violations ?? 0 }; + } + + const limit = quota.requestsPerMinute; + const burst = quota.burstAllowance; + const allowed = entry.count < limit || entry.burstCount < burst; + + if (allowed) { + entry.count++; + if (entry.count > limit) entry.burstCount++; + } else { + entry.violations++; + } + + this.usage.set(k, entry); + const remaining = Math.max(0, limit - entry.count); + const resetAt = entry.windowStart + WINDOW_MS; + return { allowed, remaining, resetAt }; + } + + getAnalytics(userId: string) { + const entries: RateLimitEntry[] = []; + for (const [, v] of this.usage) { + if (v.userId === userId) entries.push(v); + } + return entries; + } + + resetQuota(userId: string) { + for (const [k, v] of this.usage) { + if (v.userId === userId) this.usage.delete(k); + } + return { reset: true }; + } + + whitelist(userId: string, whitelisted: boolean) { + const q = this.getQuota(userId); + q.whitelisted = whitelisted; + this.quotas.set(userId, q); + return q; + } +} diff --git a/microservices/rate-limit-service/tsconfig.json b/microservices/rate-limit-service/tsconfig.json new file mode 100644 index 0000000..fcd3d4c --- /dev/null +++ b/microservices/rate-limit-service/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2020", + "declaration": false, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*"] +}