diff --git a/.env.example b/.env.example index 72f50822..ff523448 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/src/__tests__/graphqlSubscription.test.ts b/src/__tests__/graphqlSubscription.test.ts index b2061917..10adf460 100644 --- a/src/__tests__/graphqlSubscription.test.ts +++ b/src/__tests__/graphqlSubscription.test.ts @@ -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' @@ -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 diff --git a/src/api/graphql.ts b/src/api/graphql.ts index 675a070f..5e84e219 100644 --- a/src/api/graphql.ts +++ b/src/api/graphql.ts @@ -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' @@ -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 { + 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('/') } @@ -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: { diff --git a/src/app.ts b/src/app.ts index 85de28aa..1e0d8481 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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' @@ -73,6 +73,7 @@ export interface BuildAppOptions { * Optional CORS configuration override (useful in testing). */ corsConfig?: CorsConfig + graphqlUi?: GraphQLUiOptions } /** @@ -263,8 +264,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise