Skip to content
Open
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,14 @@ CORS_ADMIN_ALLOWED_ORIGINS=
# Comma-separated list of web origins explicitly blocked from calling public routes (/price, /health, /openapi.json).
# Empty/unset means all public cross-origin requests are permitted (* without credentials).
CORS_PUBLIC_BLOCKED_ORIGINS=
# --- GraphQL browser tooling ---
# GraphiQL is enabled outside production by default. In production it is disabled;
# set true only when the interactive console should be exposed.
GRAPHIQL_ENABLED=
# Schema introspection is independent from the browser IDE. It stays enabled by
# default for this public read API; set false when production schema discovery
# should be disabled.
GRAPHQL_INTROSPECTION_ENABLED=true

# --- x402 Payment Gate ---
# Stellar public key where API payments should be sent.
Expand Down
48 changes: 47 additions & 1 deletion src/__tests__/graphqlSubscription.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ vi.mock('../aggregator/vwap', () => ({ getAggregatedPrice: vi.fn() }))
vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: vi.fn() }))
vi.mock('../config', () => ({ config: { pairs: [] } }))

import { registerGraphQL } from '../api/graphql'
import { registerGraphQL, resolveGraphQLUiOptions } from '../api/graphql'
import { publishPriceUpdate, priceEmitter } from '../events'

const SUBPROTOCOL = 'graphql-transport-ws'
Expand Down Expand Up @@ -71,6 +71,52 @@ const SUBSCRIBE = (pair: string, network?: string) => ({
},
})

describe('GraphQL production UI configuration', () => {
it('disables GraphiQL by default in production while leaving POST /graphql available', async () => {
const options = resolveGraphQLUiOptions({ NODE_ENV: 'production' })
expect(options.graphiqlEnabled).toBe(false)
expect(options.introspectionEnabled).toBe(true)

const app = Fastify({ logger: false })
await registerGraphQL(app, { environment: 'production' })
await app.ready()
try {
expect((await app.inject({ method: 'GET', url: '/graphiql' })).statusCode).toBe(404)
const response = await app.inject({
method: 'POST',
url: '/graphql',
payload: { query: '{ listPairs }' },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ data: { listPairs: [] } })
} finally {
await app.close()
}
})

it('allows operators to configure the IDE and introspection independently', async () => {
expect(resolveGraphQLUiOptions({ NODE_ENV: 'production', GRAPHIQL_ENABLED: 'true' }))
.toMatchObject({ graphiqlEnabled: true, introspectionEnabled: true })
expect(resolveGraphQLUiOptions({ NODE_ENV: 'production', GRAPHQL_INTROSPECTION_ENABLED: 'false' }))
.toMatchObject({ graphiqlEnabled: false, introspectionEnabled: false })

const app = Fastify({ logger: false })
await registerGraphQL(app, { environment: 'production', introspectionEnabled: false })
await app.ready()
try {
const response = await app.inject({
method: 'POST',
url: '/graphql',
payload: { query: '{ __schema { queryType { name } } }' },
})
expect(response.statusCode).toBe(400)
expect(response.json().errors?.[0]?.message).toMatch(/introspection/i)
} finally {
await app.close()
}
})
})

describe('GraphQL priceUpdated subscription', () => {
let app: FastifyInstance
let url: string
Expand Down
47 changes: 44 additions & 3 deletions src/api/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
type DocumentNode,
type OperationDefinitionNode,
type FragmentDefinitionNode,
type FieldNode,
type ValidationContext,
} from 'graphql'
import { hasScope, extractBearer, lookupApiKey } from './auth'
import { randomUUID } from 'node:crypto'
Expand Down Expand Up @@ -458,6 +460,41 @@ function priceTopic(pair: string, network: string): string {
return `${PRICE_TOPIC}/${pair}/${network}`
}

export interface GraphQLUiOptions {
environment?: string
graphiqlEnabled?: boolean
introspectionEnabled?: boolean
}

const NoIntrospectionFieldsRule = (context: ValidationContext) => ({
Field(node: FieldNode) {
if (node.name.value === '__schema' || node.name.value === '__type') {
context.reportError(new GraphQLError('GraphQL introspection is disabled', { nodes: node }))
}
},
})

/**
* GraphiQL is an interactive console, so production defaults it off. Schema
* introspection is controlled independently: it remains on by default because
* this is a public read API and clients need schema discovery, but operators
* can disable it for production deployments that require a private schema.
*/
export function resolveGraphQLUiOptions(
env: NodeJS.ProcessEnv = process.env,
overrides: GraphQLUiOptions = {},
): Required<GraphQLUiOptions> {
const isProduction = (overrides.environment ?? env.NODE_ENV) === 'production'
const parseBoolean = (value: string | undefined, fallback: boolean) =>
value === undefined || value.trim() === '' ? fallback : value.toLowerCase() === 'true'

return {
environment: overrides.environment ?? env.NODE_ENV ?? 'development',
graphiqlEnabled: overrides.graphiqlEnabled ?? parseBoolean(env.GRAPHIQL_ENABLED, !isProduction),
introspectionEnabled: overrides.introspectionEnabled ?? parseBoolean(env.GRAPHQL_INTROSPECTION_ENABLED, true),
}
}

function makePairKey(a: string, b: string): string {
return [a, b].sort().join('/')
}
Expand Down Expand Up @@ -959,20 +996,24 @@ export const resolvers = {
},
}

export async function registerGraphQL(app: FastifyInstance) {
export async function registerGraphQL(app: FastifyInstance, uiOptions: GraphQLUiOptions = {}) {
const maxQueryDepth = getMaxGraphQLQueryDepth()
const { graphiqlEnabled, introspectionEnabled } = resolveGraphQLUiOptions(process.env, uiOptions)

await app.register(mercurius, {
schema,
resolvers,
graphiql: true,
graphiql: graphiqlEnabled,
// Mercurius 16.9.0 supplies the selected operation name to this callback.
// Its built-in queryDepth option neither expands fragments nor distinguishes
// an unselected operation in a multi-operation document. A callback cannot
// be used with Mercurius's document cache, so disable that cache rather
// than weakening the operation-aware validation policy.
cache: false,
validationRules: ({ operationName }) => queryDepthValidationRules(maxQueryDepth, operationName),
validationRules: ({ operationName }) => [
...(introspectionEnabled ? [] : [NoIntrospectionFieldsRule]),
...queryDepthValidationRules(maxQueryDepth, operationName),
],
errorFormatter: formatGraphQLErrors,
path: '/graphql',
subscription: {
Expand Down
6 changes: 3 additions & 3 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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 { registerGraphQL, type GraphQLUiOptions } from './api/graphql'
import { registerWebhookRoutes } from './routes/webhooks'
import { registerCandleRoutes } from './routes/candles'
import { registerPairsRoutes } from './routes/pairs'
Expand Down Expand Up @@ -73,6 +73,7 @@ export interface BuildAppOptions {
* Optional CORS configuration override (useful in testing).
*/
corsConfig?: CorsConfig
graphqlUi?: GraphQLUiOptions
}

/**
Expand Down Expand Up @@ -263,8 +264,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
await registerDispersionRoutes(app)
await registerHealthRoutes(app)
await registerTvlRoutes(app)
await registerGraphQL(app)

await registerGraphQL(app, options.graphqlUi)
await registerWebSocket(app)

// Global error handler: catches unhandled errors, ensuring database pool exhaustion
Expand Down
Loading