Repository navigation
Expand file tree
/
Copy pathapp.ts
More file actions
296 lines (271 loc) · 12.5 KB
/
Copy pathapp.ts
File metadata and controls
296 lines (271 loc) · 12.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
import Fastify, { type FastifyInstance, type RouteOptions } from 'fastify'
import { randomUUID } from 'node:crypto'
import cors from '@fastify/cors'
import compress from '@fastify/compress'
import { sendInternalError } from './errors'
import rateLimit from '@fastify/rate-limit'
import { registerRESTRoutes } from './api/rest'
import { registerGraphQL } from './api/graphql'
import { registerWebhookRoutes } from './routes/webhooks'
import { registerCandleRoutes } from './routes/candles'
import { registerPairsRoutes } from './routes/pairs'
import { registerPoolsRoutes } from './routes/pools'
import { registerScreenerRoutes } from './routes/screener'
import { registerMoversRoutes } from './routes/movers'
import { registerHistoryRoutes } from './api/history'
import { registerX402 } from './middleware/x402'
import { registerNetworkSelector } from './middleware/network'
import { registerHttpMetrics } from './middleware/httpMetrics'
import { registerETag } from './middleware/etag'
import { registerWebSocket } from './api/websocket'
import { registerApiKeyAuth, registerAdminAuth, isAdminConfigured } from './api/auth'
import { registerAdminRoutes } from './api/admin'
import { registerUsageRoutes } from './api/usage'
import { registerFacilitatorRoutes } from './api/facilitator'
import { registerPriceRoutes } from './routes/price'
import { registerPremiumRoutes } from './routes/premium'
import { registerVolumeRoutes } from './routes/volumes'
import { registerCoverageRoutes } from './routes/coverage'
import { registerBookRoutes } from './routes/book'
import { registerSpreadsRoutes } from './routes/spreads'
import { registerRateRoutes } from './routes/rates'
import { registerBenchmarkRoutes } from './routes/benchmark'
import { registerPegsRoutes } from './routes/pegs'
import { registerOracleRoutes } from './routes/oracle'
import { registerBasketRoutes } from './routes/basket'
import { registerCorrelationRoutes } from './routes/correlation'
import { registerDiscoveryRoutes } from './routes/discovery'
import { registerSnapshotRoutes } from './routes/snapshot'
import { registerVenueRoutes } from './routes/venues'
import { registerAssetRoutes } from './routes/assets'
import { registerSettleRoute } from './routes/facilitator'
import { registerApiVersioning, rewriteVersionedApiUrl } from './api/versioning'
import { registerDispersionRoutes } from './routes/dispersion'
import { registerHealthRoutes } from './routes/health'
import { registerTvlRoutes } from './routes/tvl'
import { registerCachePolicy } from './middleware/cachePolicy'
import { registerFiatRoutes } from './routes/fiat'
import { registerGlobalRateLimit, parseGlobalRateLimit } from './middleware/globalRateLimit'
import { getMetrics, rate_limit_rejections_total } from './metrics'
import { loggerOptions, createDestination } from './logger'
import { isPoolExhaustionError, recordPoolExhaustion } from './db'
import { createCorsDelegator, type CorsConfig } from './cors'
/**
* Routes the rate limiter skips.
*
* All three are infrastructure polled on a fixed interval, often from several
* addresses at once. A 429 during an incident would take an instance out of
* rotation exactly when its health matters most, and would be reported as
* "unhealthy" by the very probe that got throttled.
*/
const PROBE_PATHS = new Set(['/status', '/live', '/ready'])
export interface BuildAppOptions {
/**
* Invoked for every route Fastify registers, before the app is ready.
*
* Exists so the OpenAPI coverage test can enumerate the exact route surface
* this process exposes (see tests/openapi.test.ts) without re-listing the
* routes — a duplicated list would drift and defeat the point.
*/
onRoute?: (routeOptions: RouteOptions) => void
/**
* Optional CORS configuration override (useful in testing).
*/
corsConfig?: CorsConfig
}
/**
* Builds the fully-wired Fastify API — everything `src/index.ts` used to do
* inline between creating the server and calling `listen()`.
*
* Split out from `main()` so tests can boot the real route surface (all
* plugins, hooks and routes, in the same order) without connecting to
* Postgres/Redis or starting the ingesters. `index.ts` owns those side
* effects.
*
* The returned app is NOT listening; the caller calls `app.listen(...)`.
*/
export const RATE_LIMITS = {
ipDefault: 100,
authenticatedDefault: 1000,
monitoring: 1000,
timeWindow: '1 minute',
} as const
export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyInstance> {
// Share the structured logger configuration (level, redaction, dev pretty
// printing) with Fastify so `req.log` is scrubbed the same way.
const app = Fastify({
logger: { ...loggerOptions(), stream: createDestination() },
genReqId: () => randomUUID(),
// Resolves `/v1/<path>` onto the single bare route that is registered, so
// every route answers at both `/v1/...` and its unversioned alias without
// being mounted twice. See src/api/versioning.ts.
rewriteUrl: rewriteVersionedApiUrl,
})
app.setErrorHandler((error: any, req, reply) => {
const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : 500
if (statusCode < 500) {
return reply.status(statusCode).send(error)
}
return sendInternalError(reply, req, error, 'Unhandled server error')
})
// Capture route registrations first, so it observes every route below
// (including the ones registered by encapsulated plugins such as Mercurius).
if (options.onRoute) {
app.addHook('onRoute', options.onRoute)
}
// #268: one policy no longer covers public reads, credentialed calls and the
// admin surface. See src/cors.ts.
await app.register(cors, { delegator: createCorsDelegator(options.corsConfig) })
await app.register(registerETag)
await app.register(compress)
// Warn callers who use the deprecated unversioned aliases (Deprecation /
// Link headers). Registered before the routes so the hook is in place, and
// it only reads what rewriteUrl recorded — it never blocks a request.
registerApiVersioning(app)
// HTTP request/latency metrics. Registered FIRST, ahead of the network
// selector, API-key auth, the rate limiter and x402, so that requests those
// plugins reject (400/401/402/429) still have their timer started and are
// counted. See src/middleware/httpMetrics.ts.
await app.register(registerHttpMetrics)
// Resolves the per-request Stellar network (?network= query param / x-network
// header) onto req.network, validating it (400 on an unrecognised value).
// Runs in onRequest, ahead of API-key auth/rate-limiting/x402 and every route
// handler, so all of them can read req.network.
await app.register(registerNetworkSelector)
// API-key authentication — validates Authorization: Bearer <key> and attaches
// per-key quota metadata to req.apiKey. Registered BEFORE the rate limiter so
// that req.apiKey is populated when the limiter evaluates its per-key quota
// (both run in the onRequest phase, in registration order). Disabled when
// REQUIRE_API_KEY=false. Routes marked `config.public` bypass auth.
if (process.env.REQUIRE_API_KEY !== 'false') {
await app.register(registerApiKeyAuth)
} else {
app.log.warn('[auth] REQUIRE_API_KEY=false — API key authentication disabled')
}
// Structural admin authentication hook: intercepts every /admin/* route and
// ensures ADMIN_TOKEN is enforced without requiring per-handler manual guards.
await app.register(registerAdminAuth)
// Per-key rate quotas: the limit is derived from the authenticated key's
// metadata (req.apiKey, set by the auth hook above). Unauthenticated/public
// requests fall back to a conservative shared limit keyed by IP. The IP
// fallback is overridable via RATE_LIMIT_IP_MAX so load tests (which flood
// from a single IP without a key) can measure the endpoint, not the limiter.
const ipRateLimitMax = parseInt(process.env.RATE_LIMIT_IP_MAX ?? String(RATE_LIMITS.ipDefault), 10)
await app.register(rateLimit, {
max: (req) => req.apiKey?.ratePerMin ?? ipRateLimitMax,
timeWindow: '1 minute',
keyGenerator: (req) => req.apiKey?.id ?? req.ip,
// PROBE_PATHS, not just /status: /live and /ready are polled by the
// platform on a fixed interval and must never be rate limited.
allowList: (req) => PROBE_PATHS.has(req.url),
errorResponseBuilder: (req, context) => {
rate_limit_rejections_total.inc({ scope: req.apiKey ? 'key' : 'ip' })
const seconds = typeof context.after === 'number' ? context.after : (Math.ceil(context.ttl / 1000) || 60)
return {
statusCode: 429,
error: 'Too Many Requests',
code: 'rate_limited',
message: `Rate limit exceeded, retry in ${seconds} seconds`,
retryAfter: seconds,
}
},
})
// Ensure Retry-After header is always present on 429 responses (#272)
app.addHook('onSend', async (_req, reply, payload) => {
if (reply.statusCode === 429 && !reply.hasHeader('retry-after')) {
try {
const body = typeof payload === 'string' ? JSON.parse(payload) : payload
if (body?.retryAfter) {
reply.header('Retry-After', String(body.retryAfter))
}
} catch {
// non-fatal
}
}
return payload
})
// The per-instance ceiling runs after the per-key/IP limiter. Health checks
// bypass it, and expensive route templates are shed before other routes.
registerGlobalRateLimit(app, parseGlobalRateLimit(process.env.GLOBAL_RATE_LIMIT_MAX))
// Per-route ceiling for the probe routes. Inert while they stay allow-listed
// above — @fastify/rate-limit consults `allowList` before it ever reads
// `max` — but kept so removing the allow-list does not silently drop these to
// the shared per-IP default, which is far too low for an infrastructure
// poller.
app.addHook('onRoute', (routeOptions) => {
if (routeOptions.url && PROBE_PATHS.has(routeOptions.url)) {
routeOptions.config = {
...routeOptions.config,
rateLimit: {
max: 1000,
timeWindow: '1 minute'
}
}
}
})
// Admin endpoints (key issuance/revocation) — gated by ADMIN_TOKEN. They
// declare `{ admin: true, auth: 'admin' }` (#514), which both steps the
// API-key hook aside and puts them behind registerAdminAuth above.
if (!isAdminConfigured()) {
app.log.warn('ADMIN_TOKEN is not configured; admin routes (/admin/*) are disabled')
}
await registerAdminRoutes(app)
await registerUsageRoutes(app)
await app.register(registerFacilitatorRoutes)
await app.register(registerX402)
// Per-route Cache-Control policy (issue #266) — every response carries a
// deliberate Cache-Control header. Registered before the routes so the
// onSend hook observes all of them.
await app.register(registerCachePolicy)
await registerRESTRoutes(app)
await registerWebhookRoutes(app)
await registerCandleRoutes(app)
await registerPairsRoutes(app)
await registerPoolsRoutes(app)
await registerScreenerRoutes(app)
await registerMoversRoutes(app)
await registerHistoryRoutes(app)
await registerPriceRoutes(app)
await registerPremiumRoutes(app)
await registerVolumeRoutes(app)
await registerCoverageRoutes(app)
await registerBookRoutes(app)
await registerSpreadsRoutes(app)
await registerRateRoutes(app)
await registerBenchmarkRoutes(app)
await registerPegsRoutes(app)
await registerOracleRoutes(app)
await registerBasketRoutes(app)
await registerCorrelationRoutes(app)
await registerDiscoveryRoutes(app)
await registerSnapshotRoutes(app)
await registerVenueRoutes(app)
await registerFiatRoutes(app)
await registerAssetRoutes(app)
await registerSettleRoute(app)
await registerDispersionRoutes(app)
await registerHealthRoutes(app)
await registerTvlRoutes(app)
await registerGraphQL(app)
await registerWebSocket(app)
// Global error handler: catches unhandled errors, ensuring database pool exhaustion
// is diagnosed with distinct logging and metric incrementation rather than a generic 500.
app.setErrorHandler((error, req, reply) => {
if (isPoolExhaustionError(error)) {
recordPoolExhaustion(error, `http:${req.method}:${req.url}`)
return reply.status(503).send({
error: 'Service Unavailable',
message: 'Database connection pool exhausted. Please retry shortly.',
code: 'DB_POOL_EXHAUSTED',
})
}
req.log.error(error)
return reply.send(error)
})
// Prometheus metrics endpoint (un-gated, public — no API key required)
app.get('/metrics', { config: { public: true } }, async (req, reply) => {
reply.type('text/plain; version=0.0.4; charset=utf-8')
return await getMetrics()
})
return app
}