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
25 changes: 22 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,28 @@ LOG_LEVEL=info
# Format: postgresql://user:password@host:port/database
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/guildpass"

# ============================================================================
# RATE LIMITING (Optional - sensible defaults provided)
# ============================================================================

# Set to false to disable rate limiting entirely (useful for local development
# or integration tests that fire many requests). Default: true
# RATE_LIMIT_ENABLED=true

# Time window for rate limit counters, in milliseconds (default: 60000 = 1 minute)
# RATE_LIMIT_WINDOW_MS=60000

# Maximum requests per IP per window for standard endpoints (default: 100)
# RATE_LIMIT_DEFAULT_MAX=100

# Maximum requests per IP per window for expensive endpoints such as
# GET /v1/communities/:id/members (default: 20)
# RATE_LIMIT_EXPENSIVE_MAX=20

# Optional Redis connection string for distributed rate limiting across multiple
# instances. When omitted, an in-memory store is used (not shared across replicas).
# REDIS_URL="redis://localhost:6379"

# ============================================================================
# FUTURE USE (not yet validated)
# ============================================================================
Expand All @@ -36,9 +58,6 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/guildpass"
# MEMBERSHIP_NFT_ADDRESS=""
# CHAIN_ID=31337

# Reserved for future caching
# REDIS_URL="redis://localhost:6379"

# Reserved for future metrics auth
# METRICS_TOKEN=""

Expand Down
1 change: 1 addition & 0 deletions apps/access-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"test": "jest --passWithNoTests"
},
"dependencies": {
"@fastify/rate-limit": "^9.1.0",
"@fastify/swagger": "^8.14.0",
"@fastify/swagger-ui": "^2.0.1",
"@guildpass/policy-engine": "workspace:*",
Expand Down
43 changes: 40 additions & 3 deletions apps/access-api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import swagger from '@fastify/swagger';
import swaggerUi from '@fastify/swagger-ui';
import rateLimit from '@fastify/rate-limit';

import { buildPinoHttp } from './observability/logger';
import { registry, metrics } from './observability/metrics';
Expand Down Expand Up @@ -130,13 +131,47 @@ export async function buildApp(): Promise<FastifyInstance> {
await app.register(swaggerUi, { routePrefix: '/docs' });

// -----------------------------------------------------------------------
// 4. Prometheus metrics endpoint
// 4. Rate limiting
// Enabled by default; set RATE_LIMIT_ENABLED=false to disable.
// Health endpoints opt-out via { config: { rateLimit: false } }.
// Expensive endpoints declare a tighter ceiling in their route config.
// -----------------------------------------------------------------------
if (config.rateLimitEnabled) {
await app.register(rateLimit, {
global: true,
max: config.rateLimitDefaultMax,
timeWindow: config.rateLimitWindowMs,
keyGenerator: (req) => {
const forwarded = req.headers['x-forwarded-for'];
const ip = Array.isArray(forwarded)
? forwarded[0]
: forwarded?.split(',')[0]?.trim();
return ip ?? req.ip;
},
errorResponseBuilder: (_req, context) => ({
statusCode: 429,
error: 'Too Many Requests',
message: `Rate limit exceeded. Retry after ${Math.ceil(context.ttl / 1000)} seconds.`,
retryAfter: Math.ceil(context.ttl / 1000),
}),
addHeaders: {
'x-ratelimit-limit': true,
'x-ratelimit-remaining': true,
'x-ratelimit-reset': true,
'retry-after': true,
},
});
}

// -----------------------------------------------------------------------
// 6. Prometheus metrics endpoint
// Secured by a simple pre-handler so it is never exposed publicly.
// Set METRICS_TOKEN to a non-empty string to enable bearer-token auth.
// -----------------------------------------------------------------------
app.get(
'/metrics',
{
config: { rateLimit: false },
schema: {
summary: 'Prometheus metrics scrape endpoint',
tags: ['Observability'],
Expand All @@ -157,7 +192,7 @@ export async function buildApp(): Promise<FastifyInstance> {
);

// -----------------------------------------------------------------------
// 5. Health endpoints
// 7. Health endpoints
//
// GET /health/live – liveness probe
// Answers: "Is the process alive and the event loop responsive?"
Expand All @@ -171,6 +206,7 @@ export async function buildApp(): Promise<FastifyInstance> {
app.get(
'/health/live',
{
config: { rateLimit: false },
schema: {
summary: 'Liveness probe – is the process alive?',
tags: ['Health'],
Expand All @@ -193,6 +229,7 @@ export async function buildApp(): Promise<FastifyInstance> {
app.get(
'/health/ready',
{
config: { rateLimit: false },
schema: {
summary: 'Readiness probe – can we serve traffic?',
tags: ['Health'],
Expand Down Expand Up @@ -233,7 +270,7 @@ export async function buildApp(): Promise<FastifyInstance> {
);

// -----------------------------------------------------------------------
// 6. Business routes
// 8. Business routes
// -----------------------------------------------------------------------
registerRoutes(app);

Expand Down
27 changes: 27 additions & 0 deletions apps/access-api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,28 @@ const ConfigSchema = z.object({
.int()
.positive()
.default(60_000),

// Rate limiting
rateLimitEnabled: z
.string()
.transform((v: string) => v !== 'false' && v !== '0')
.default('true'),
rateLimitWindowMs: z.coerce
.number()
.int()
.positive()
.default(60_000),
rateLimitDefaultMax: z.coerce
.number()
.int()
.positive()
.default(100),
rateLimitExpensiveMax: z.coerce
.number()
.int()
.positive()
.default(20),
redisUrl: z.string().optional(),
});

export type Config = z.infer<typeof ConfigSchema>;
Expand All @@ -64,6 +86,11 @@ function validateConfig(): Config {
databaseUrl: process.env.DATABASE_URL,
logLevel: process.env.LOG_LEVEL,
reconciliationIntervalMs: process.env.RECONCILIATION_INTERVAL_MS,
rateLimitEnabled: process.env.RATE_LIMIT_ENABLED,
rateLimitWindowMs: process.env.RATE_LIMIT_WINDOW_MS,
rateLimitDefaultMax: process.env.RATE_LIMIT_DEFAULT_MAX,
rateLimitExpensiveMax: process.env.RATE_LIMIT_EXPENSIVE_MAX,
redisUrl: process.env.REDIS_URL,
};

const result = ConfigSchema.safeParse(envVars);
Expand Down
197 changes: 197 additions & 0 deletions apps/access-api/test/rateLimit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import Fastify, { FastifyInstance } from 'fastify';
import rateLimit from '@fastify/rate-limit';

interface RateLimitOptions {
enabled: boolean;
max: number;
expensiveMax: number;
timeWindow: number;
}

async function buildRateLimitedApp(opts: RateLimitOptions): Promise<FastifyInstance> {
const app = Fastify();

if (opts.enabled) {
await app.register(rateLimit, {
global: true,
max: opts.max,
timeWindow: opts.timeWindow,
errorResponseBuilder: (_req, context) => ({
statusCode: 429,
error: 'Too Many Requests',
message: `Rate limit exceeded. Retry after ${Math.ceil(context.ttl / 1000)} seconds.`,
retryAfter: Math.ceil(context.ttl / 1000),
}),
addHeaders: {
'x-ratelimit-limit': true,
'x-ratelimit-remaining': true,
'x-ratelimit-reset': true,
'retry-after': true,
},
});
}

app.get('/health/live', { config: { rateLimit: false } }, async () => {
return { status: 'ok' };
});

app.get('/v1/access/check', async () => {
return { allowed: true };
});

app.get('/v1/communities/:communityId/members', {
config: {
rateLimit: opts.enabled
? { max: opts.expensiveMax, timeWindow: opts.timeWindow }
: false,
},
}, async () => {
return { members: [] };
});

await app.ready();
return app;
}

describe('Rate limiting — allowed requests', () => {
let app: FastifyInstance;

beforeEach(async () => {
app = await buildRateLimitedApp({
enabled: true,
max: 5,
expensiveMax: 2,
timeWindow: 60_000,
});
});

afterEach(async () => {
await app.close();
});

it('allows requests up to the configured limit', async () => {
for (let i = 0; i < 5; i++) {
const res = await app.inject({ method: 'GET', url: '/v1/access/check' });
expect(res.statusCode).toBe(200);
}
});

it('attaches rate limit headers to responses', async () => {
const res = await app.inject({ method: 'GET', url: '/v1/access/check' });
expect(res.statusCode).toBe(200);
expect(res.headers['x-ratelimit-limit']).toBeDefined();
expect(res.headers['x-ratelimit-remaining']).toBeDefined();
expect(res.headers['x-ratelimit-reset']).toBeDefined();
});
});

describe('Rate limiting — blocked requests', () => {
let app: FastifyInstance;

beforeEach(async () => {
app = await buildRateLimitedApp({
enabled: true,
max: 3,
expensiveMax: 2,
timeWindow: 60_000,
});
});

afterEach(async () => {
await app.close();
});

it('returns 429 after the limit is exceeded', async () => {
for (let i = 0; i < 3; i++) {
const res = await app.inject({ method: 'GET', url: '/v1/access/check' });
expect(res.statusCode).toBe(200);
}

const blocked = await app.inject({ method: 'GET', url: '/v1/access/check' });
expect(blocked.statusCode).toBe(429);
});

it('returns a JSON body with error and retryAfter on 429', async () => {
for (let i = 0; i < 3; i++) {
await app.inject({ method: 'GET', url: '/v1/access/check' });
}

const blocked = await app.inject({ method: 'GET', url: '/v1/access/check' });
expect(blocked.statusCode).toBe(429);

const body = blocked.json();
expect(body.error).toBe('Too Many Requests');
expect(body.message).toMatch(/Rate limit exceeded/);
expect(typeof body.retryAfter).toBe('number');
});

it('returns 429 on expensive endpoint after its stricter limit', async () => {
for (let i = 0; i < 2; i++) {
const res = await app.inject({
method: 'GET',
url: '/v1/communities/community-1/members',
});
expect(res.statusCode).toBe(200);
}

const blocked = await app.inject({
method: 'GET',
url: '/v1/communities/community-1/members',
});
expect(blocked.statusCode).toBe(429);
});
});

describe('Rate limiting — disabled', () => {
let app: FastifyInstance;

beforeEach(async () => {
app = await buildRateLimitedApp({
enabled: false,
max: 2,
expensiveMax: 1,
timeWindow: 60_000,
});
});

afterEach(async () => {
await app.close();
});

it('allows unlimited requests when rate limiting is disabled', async () => {
for (let i = 0; i < 10; i++) {
const res = await app.inject({ method: 'GET', url: '/v1/access/check' });
expect(res.statusCode).toBe(200);
}
});

it('does not attach x-ratelimit headers when disabled', async () => {
const res = await app.inject({ method: 'GET', url: '/v1/access/check' });
expect(res.headers['x-ratelimit-limit']).toBeUndefined();
expect(res.headers['x-ratelimit-remaining']).toBeUndefined();
});
});

describe('Rate limiting — health check exemption', () => {
let app: FastifyInstance;

beforeEach(async () => {
app = await buildRateLimitedApp({
enabled: true,
max: 2,
expensiveMax: 1,
timeWindow: 60_000,
});
});

afterEach(async () => {
await app.close();
});

it('never rate-limits the health/live endpoint', async () => {
for (let i = 0; i < 10; i++) {
const res = await app.inject({ method: 'GET', url: '/health/live' });
expect(res.statusCode).toBe(200);
}
});
});
Loading