Skip to content

Commit 76afccc

Browse files
committed
chore: setup api route for chat streaming
1 parent 9488bb6 commit 76afccc

File tree

10 files changed

+441
-5
lines changed

10 files changed

+441
-5
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { Request } from 'express'
2+
import { describe, expect, it } from 'vitest'
3+
4+
import { getClientIp } from '../get-client-ip'
5+
6+
describe('getClientIp', () => {
7+
it('should return Cloudflare IP when cf-connecting-ip header is present', () => {
8+
const mockReq = {
9+
headers: {
10+
'cf-connecting-ip': '203.0.113.42',
11+
},
12+
socket: {
13+
remoteAddress: '192.168.1.1',
14+
},
15+
} as unknown as Request
16+
17+
expect(getClientIp(mockReq)).toBe('203.0.113.42')
18+
})
19+
20+
it('should return socket remote address when no CF header', () => {
21+
const mockReq = {
22+
headers: {},
23+
socket: {
24+
remoteAddress: '192.168.1.1',
25+
},
26+
} as unknown as Request
27+
28+
expect(getClientIp(mockReq)).toBe('192.168.1.1')
29+
})
30+
31+
it('should trim and return first IP when multiple IPs in remote address', () => {
32+
const mockReq = {
33+
headers: {},
34+
socket: {
35+
remoteAddress: '192.168.1.1, 10.0.0.1, 172.16.0.1',
36+
},
37+
} as unknown as Request
38+
39+
expect(getClientIp(mockReq)).toBe('192.168.1.1')
40+
})
41+
42+
it('should return "unknown" when no IP information is available', () => {
43+
const mockReq = {
44+
headers: {},
45+
socket: {},
46+
} as unknown as Request
47+
48+
expect(getClientIp(mockReq)).toBe('unknown')
49+
})
50+
51+
it('should prioritize CF header over socket address', () => {
52+
const mockReq = {
53+
headers: {
54+
'cf-connecting-ip': '203.0.113.1',
55+
},
56+
socket: {
57+
remoteAddress: '192.168.1.100',
58+
},
59+
} as unknown as Request
60+
61+
expect(getClientIp(mockReq)).toBe('203.0.113.1')
62+
})
63+
64+
it('should handle IPv6 addresses', () => {
65+
const mockReq = {
66+
headers: {},
67+
socket: {
68+
remoteAddress: '2001:db8::1',
69+
},
70+
} as unknown as Request
71+
72+
expect(getClientIp(mockReq)).toBe('2001:db8::1')
73+
})
74+
})

packages/backend/src/helpers/authentication.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getLoggedInUser,
99
parseAdminToken,
1010
} from '@/helpers/auth'
11+
import { getClientIp } from '@/helpers/get-client-ip'
1112
import { UnauthenticatedContext } from '@/types/express/context'
1213

1314
export const setCurrentUserContext = async ({
@@ -64,11 +65,7 @@ const isAdminOperation = rule()(
6465

6566
const rateLimitRule = createRateLimitRule({
6667
identifyContext: (ctx: UnauthenticatedContext) => {
67-
// get ip address of request in this order: cf-connecting-ip -> remoteAddress
68-
const userIp =
69-
(ctx.req.headers['cf-connecting-ip'] as string) ||
70-
ctx.req.socket.remoteAddress.split(',')[0].trim()
71-
return userIp
68+
return getClientIp(ctx.req)
7269
},
7370
// recommended flag: https://github.com/teamplanes/graphql-rate-limit#enablebatchrequestcache
7471
enableBatchRequestCache: true,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { Request } from 'express'
2+
3+
/**
4+
* Get client IP address from request.
5+
* Checks in this order:
6+
* 1. Cloudflare header (cf-connecting-ip)
7+
* 2. Socket remote address
8+
*
9+
* This is the same logic used in GraphQL authentication.
10+
*/
11+
export function getClientIp(req: Request): string {
12+
const cfIp = req.headers['cf-connecting-ip'] as string
13+
if (cfIp) {
14+
return cfIp
15+
}
16+
17+
const remoteAddress = req.socket.remoteAddress
18+
if (remoteAddress) {
19+
return remoteAddress.split(',')[0].trim()
20+
}
21+
22+
return 'unknown'
23+
}
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import type { NextFunction, Request, Response } from 'express'
2+
import { RateLimiterRes } from 'rate-limiter-flexible'
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
import { rateLimitApi } from '../../middleware/rate-limit'
6+
7+
const mocks = vi.hoisted(() => ({
8+
rateLimiterRedis: {
9+
consume: vi.fn(),
10+
},
11+
logger: {
12+
warn: vi.fn(),
13+
error: vi.fn(),
14+
},
15+
createRedisClient: vi.fn(),
16+
}))
17+
18+
vi.mock('rate-limiter-flexible', async (importOriginal) => {
19+
const actual = await importOriginal<typeof import('rate-limiter-flexible')>()
20+
return {
21+
...actual,
22+
RateLimiterRedis: vi.fn(function () {
23+
return mocks.rateLimiterRedis
24+
}),
25+
}
26+
})
27+
28+
vi.mock('@/helpers/logger', () => ({
29+
default: mocks.logger,
30+
}))
31+
32+
vi.mock('@/config/redis', () => ({
33+
createRedisClient: mocks.createRedisClient,
34+
REDIS_DB_INDEX: {
35+
RATE_LIMIT: 'rate-limit',
36+
},
37+
}))
38+
39+
describe('Rate Limiting Middleware', () => {
40+
let mockReq: Partial<Request>
41+
let mockRes: Partial<Response>
42+
let mockNext: NextFunction
43+
44+
beforeEach(() => {
45+
mockReq = {
46+
headers: {},
47+
socket: {
48+
remoteAddress: '127.0.0.1',
49+
} as any,
50+
context: {
51+
currentUser: {
52+
id: 'test-user-id',
53+
54+
} as any,
55+
isAdminOperation: false,
56+
} as any,
57+
}
58+
59+
mockRes = {
60+
status: vi.fn().mockReturnThis(),
61+
json: vi.fn(),
62+
} as Partial<Response>
63+
64+
mockNext = vi.fn()
65+
66+
vi.clearAllMocks()
67+
})
68+
69+
afterEach(() => {
70+
vi.restoreAllMocks()
71+
})
72+
73+
describe('rateLimitApi', () => {
74+
it('should allow request when under rate limit', async () => {
75+
mocks.rateLimiterRedis.consume.mockResolvedValueOnce({})
76+
77+
await rateLimitApi(mockReq as Request, mockRes as Response, mockNext)
78+
79+
expect(mocks.rateLimiterRedis.consume).toHaveBeenCalledWith(
80+
'test-user-id',
81+
)
82+
expect(mockNext).toHaveBeenCalledOnce()
83+
expect(mockRes.status).not.toHaveBeenCalled()
84+
})
85+
86+
it('should use user ID for rate limiting when user is authenticated', async () => {
87+
mocks.rateLimiterRedis.consume.mockResolvedValueOnce({})
88+
89+
await rateLimitApi(mockReq as Request, mockRes as Response, mockNext)
90+
91+
expect(mocks.rateLimiterRedis.consume).toHaveBeenCalledWith(
92+
'test-user-id',
93+
)
94+
expect(mockNext).toHaveBeenCalledOnce()
95+
})
96+
97+
it('should use IP address when user is not authenticated', async () => {
98+
mockReq.context = {
99+
currentUser: null,
100+
isAdminOperation: false,
101+
} as any
102+
mockReq.socket = {
103+
remoteAddress: '192.168.1.1',
104+
} as any
105+
106+
mocks.rateLimiterRedis.consume.mockResolvedValueOnce({})
107+
108+
await rateLimitApi(mockReq as Request, mockRes as Response, mockNext)
109+
110+
expect(mocks.rateLimiterRedis.consume).toHaveBeenCalledWith('192.168.1.1')
111+
expect(mockNext).toHaveBeenCalledOnce()
112+
})
113+
114+
it('should use Cloudflare IP when available', async () => {
115+
mockReq.context = {
116+
currentUser: null,
117+
isAdminOperation: false,
118+
} as any
119+
mockReq.headers = {
120+
'cf-connecting-ip': '203.0.113.42',
121+
}
122+
123+
mocks.rateLimiterRedis.consume.mockResolvedValueOnce({})
124+
125+
await rateLimitApi(mockReq as Request, mockRes as Response, mockNext)
126+
127+
expect(mocks.rateLimiterRedis.consume).toHaveBeenCalledWith(
128+
'203.0.113.42',
129+
)
130+
expect(mockNext).toHaveBeenCalledOnce()
131+
})
132+
133+
it('should return 429 when rate limit is exceeded', async () => {
134+
const rateLimitError = Object.assign(new RateLimiterRes(), {
135+
msBeforeNext: 5000,
136+
})
137+
138+
mocks.rateLimiterRedis.consume.mockRejectedValueOnce(rateLimitError)
139+
140+
await rateLimitApi(mockReq as Request, mockRes as Response, mockNext)
141+
142+
expect(mockRes.status).toHaveBeenCalledWith(429)
143+
expect(mockRes.json).toHaveBeenCalledWith({
144+
error: 'Too many requests',
145+
message: 'Rate limit exceeded. Please try again later.',
146+
})
147+
expect(mockNext).not.toHaveBeenCalled()
148+
})
149+
150+
it('should log rate limit violations', async () => {
151+
const rateLimitError = Object.assign(new RateLimiterRes(), {
152+
msBeforeNext: 3000,
153+
})
154+
155+
mocks.rateLimiterRedis.consume.mockRejectedValueOnce(rateLimitError)
156+
157+
await rateLimitApi(mockReq as Request, mockRes as Response, mockNext)
158+
159+
expect(mocks.logger.warn).toHaveBeenCalledWith(
160+
'API endpoint rate limited',
161+
expect.objectContaining({
162+
event: 'api-rate-limited',
163+
userId: 'test-user-id',
164+
remainingMs: 3000,
165+
}),
166+
)
167+
})
168+
169+
it('should handle errors gracefully and continue', async () => {
170+
const genericError = new Error('Redis connection failed')
171+
mocks.rateLimiterRedis.consume.mockRejectedValueOnce(genericError)
172+
173+
await rateLimitApi(mockReq as Request, mockRes as Response, mockNext)
174+
175+
expect(mocks.logger.error).toHaveBeenCalledWith(
176+
'Error in rate limiting middleware',
177+
{ error: genericError },
178+
)
179+
expect(mockNext).toHaveBeenCalledOnce()
180+
expect(mockRes.status).not.toHaveBeenCalled()
181+
})
182+
183+
it('should use IP fallback when no user is available', async () => {
184+
mockReq.context = {
185+
currentUser: null,
186+
isAdminOperation: false,
187+
} as any
188+
mockReq.socket = {
189+
remoteAddress: '10.0.0.1',
190+
} as any
191+
mockReq.headers = {}
192+
193+
mocks.rateLimiterRedis.consume.mockResolvedValueOnce({})
194+
195+
await rateLimitApi(mockReq as Request, mockRes as Response, mockNext)
196+
197+
expect(mocks.rateLimiterRedis.consume).toHaveBeenCalledWith('10.0.0.1')
198+
expect(mockNext).toHaveBeenCalledOnce()
199+
})
200+
})
201+
})
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Router } from 'express'
2+
3+
const router = Router()
4+
5+
// Mount individual API routes
6+
7+
// Future routes can be added here:
8+
// router.use('/users', usersRouter)
9+
// router.use('/analytics', analyticsRouter)
10+
// etc.
11+
12+
export default router
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { NextFunction, Request, Response } from 'express'
2+
3+
import { setCurrentUserContext as setGraphQLContext } from '@/helpers/authentication'
4+
import type Context from '@/types/express/context'
5+
6+
/**
7+
* Middleware to set the current user context on the request object.
8+
* This reuses the same authentication logic as GraphQL mutations.
9+
*/
10+
export async function setCurrentUserContext(
11+
req: Request,
12+
res: Response,
13+
next: NextFunction,
14+
) {
15+
// Reuse the GraphQL context creation logic
16+
const context = await setGraphQLContext({ req, res })
17+
18+
// Attach context to the request object
19+
req.context = context as Context
20+
21+
next()
22+
}
23+
24+
/**
25+
* Middleware to ensure the user is authenticated before allowing the request to proceed.
26+
* Returns 401 if the user is not authenticated.
27+
*/
28+
export function requireAuthentication(
29+
req: Request,
30+
res: Response,
31+
next: NextFunction,
32+
) {
33+
if (!req.context?.currentUser) {
34+
res.status(401).json({ error: 'Not Authorised!' })
35+
return
36+
}
37+
38+
next()
39+
}
40+
41+
/**
42+
* Type guard to ensure the request has an authenticated context.
43+
* Use this in route handlers to get type-safe access to currentUser.
44+
*/
45+
export function getAuthenticatedContext(req: Request): Context {
46+
if (!req.context?.currentUser) {
47+
throw new Error('User must be authenticated')
48+
}
49+
50+
return req.context as Context
51+
}

0 commit comments

Comments
 (0)