From 8515630f496409f05e31c846c6c2b33a0df2e3c8 Mon Sep 17 00:00:00 2001 From: Nabarun Gogoi Date: Thu, 12 Oct 2023 10:58:13 +0530 Subject: [PATCH] Add `validateGQLRequest` method in PaymentsManager (#426) * Refactor GQL validation for payment * Export payment util const PAYMENT_HEADER_KEY * Check payment header only for paid queries --- packages/util/src/payments.ts | 150 ++++++++++++++++++++-------------- 1 file changed, 90 insertions(+), 60 deletions(-) diff --git a/packages/util/src/payments.ts b/packages/util/src/payments.ts index a1708be9..b6abdf2c 100644 --- a/packages/util/src/payments.ts +++ b/packages/util/src/payments.ts @@ -24,7 +24,7 @@ const log = debug('laconic:payments'); const IntrospectionQuery = 'IntrospectionQuery'; const IntrospectionQuerySelection = '__schema'; -const PAYMENT_HEADER_KEY = 'x-payment'; +export const PAYMENT_HEADER_KEY = 'x-payment'; const PAYMENT_HEADER_REGEX = /vhash:(.*),vsig:(.*)/; const ERR_FREE_QUOTA_EXHUASTED = 'Free quota exhausted'; @@ -355,78 +355,108 @@ export const paymentsPlugin = (paymentsManager?: PaymentsManager): ApolloServerP const querySelections = requestContext.operation?.selectionSet.selections .map((selection: any) => (selection as FieldNode).name.value); - // Continue if it's an introspection query for schema - // (made by ApolloServer playground / default landing page) - if ( - requestContext.operationName === IntrospectionQuery && - querySelections && querySelections.length === 1 && - querySelections[0] === IntrospectionQuerySelection - ) { + try { + await validateGQLRequest( + paymentsManager, + { + querySelections, + operationName: requestContext.operationName, + paymentHeader: requestContext.request.http?.headers.get(PAYMENT_HEADER_KEY) + } + ); + return null; - } - - const paymentHeader = requestContext.request.http?.headers.get(PAYMENT_HEADER_KEY); - if (paymentHeader == null) { - return { - errors: [{ message: ERR_HEADER_MISSING }], - http: new HTTPResponse(undefined, { - headers: requestContext.response?.http?.headers, - status: HTTP_CODE_BAD_REQUEST - }) - }; - } - - let vhash: string, vsig: string; - const match = paymentHeader.match(PAYMENT_HEADER_REGEX); - - if (match) { - [, vhash, vsig] = match; - } else { - return { - errors: [{ message: ERR_INVALID_PAYMENT_HEADER }], - http: new HTTPResponse(undefined, { - headers: requestContext.response?.http?.headers, - status: HTTP_CODE_BAD_REQUEST - }) - }; - } - - const signerAddress = nitroUtils.getSignerAddress(vhash, vsig); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for await (const querySelection of querySelections ?? []) { - if (paymentsManager.freeQueriesList.includes(querySelection)) { - continue; - } - - // Serve a query for free if rate is not configured - const configuredQueryCost = paymentsManager.queryRates[querySelection]; - if (configuredQueryCost === undefined) { - log(`Query rate not configured for "${querySelection}", serving a free query to ${signerAddress}`); - continue; - } - - const [allowRequest, rejectionMessage] = await paymentsManager.allowRequest(vhash, signerAddress, querySelection); - if (!allowRequest) { - const failResponse: GraphQLResponse = { - errors: [{ message: rejectionMessage }], + } catch (error) { + if (error instanceof GQLPaymentError) { + return { + errors: [{ message: error.message }], http: new HTTPResponse(undefined, { headers: requestContext.response?.http?.headers, - status: HTTP_CODE_PAYMENT_REQUIRED + status: error.status }) }; - - return failResponse; } - } - return null; + throw error; + } } }; } }; }; +class GQLPaymentError extends Error { + status: number; + + constructor (message: string, status: number) { + super(message); + this.status = status; + } +} + +export const validateGQLRequest = async ( + paymentsManager: PaymentsManager, + { operationName, querySelections, paymentHeader }: { + operationName?: string | null; + querySelections?: string[]; + paymentHeader?: string | null; + } +): Promise => { + // Return true if it's an introspection query for schema + // (made by ApolloServer playground / default landing page) + if ( + operationName === IntrospectionQuery && + querySelections && querySelections.length === 1 && + querySelections[0] === IntrospectionQuerySelection + ) { + return true; + } + + const paidQuerySelections = (querySelections ?? []).filter(querySelection => { + if (paymentsManager.freeQueriesList.includes(querySelection)) { + return false; + } + + // Serve a query for free if rate is not configured + const configuredQueryCost = paymentsManager.queryRates[querySelection]; + if (configuredQueryCost === undefined) { + log(`Query rate not configured for "${querySelection}", serving free query`); + return false; + } + + return true; + }); + + // Return true if no paid queries exist + if (!paidQuerySelections.length) { + return true; + } + + if (!paymentHeader) { + throw new GQLPaymentError(ERR_HEADER_MISSING, HTTP_CODE_BAD_REQUEST); + } + + let vhash: string, vsig: string; + const match = paymentHeader.match(PAYMENT_HEADER_REGEX); + + if (match) { + [, vhash, vsig] = match; + } else { + throw new GQLPaymentError(ERR_INVALID_PAYMENT_HEADER, HTTP_CODE_BAD_REQUEST); + } + + const signerAddress = nitroUtils.getSignerAddress(vhash, vsig); + + for await (const querySelection of paidQuerySelections) { + const [allowRequest, rejectionMessage] = await paymentsManager.allowRequest(vhash, signerAddress, querySelection); + if (!allowRequest) { + throw new GQLPaymentError(rejectionMessage, HTTP_CODE_PAYMENT_REQUIRED); + } + } + + return true; +}; + // Helper method to modify a given JsonRpcProvider to make payment for required methods // and attach the voucher details in reqeust URL export const setupProviderWithPayments = (