diff --git a/packages/backend/package.json b/packages/backend/package.json index 712d8bb674..837372ef77 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -55,7 +55,7 @@ "@graphql-tools/schema": "^10.0.3", "@interledger/http-signature-utils": "2.0.2", "@interledger/open-payments": "6.8.0", - "@interledger/openapi": "1.2.1", + "@interledger/openapi": "2.0.1", "@interledger/pay": "0.4.0-alpha.9", "@interledger/stream-receiver": "^0.3.3-alpha.3", "@koa/cors": "^5.0.0", diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 7e060be49f..f714f6f6a5 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -86,6 +86,7 @@ import { PaymentMethodHandlerService } from './payment-method/handler/service' import { IlpPaymentService } from './payment-method/ilp/service' import { TelemetryService } from './telemetry/service' import { ApolloArmor } from '@escape.tech/graphql-armor' +import { openPaymentsServerErrorMiddleware } from './open_payments/route-errors' export interface AppContextData { logger: Logger container: AppContainer @@ -388,6 +389,7 @@ export class App { router.get('/healthz', (ctx: AppContext): void => { ctx.status = 200 }) + router.use(openPaymentsServerErrorMiddleware) const walletAddressKeyRoutes = await this.container.use( 'walletAddressKeyRoutes' @@ -403,6 +405,11 @@ export class App { const { resourceServerSpec, walletAddressServerSpec } = await this.container.use('openApi') + const validatorMiddlewareOptions = { + validateRequest: true, + validateResponse: process.env.NODE_ENV !== 'production' + } + // POST /incoming-payments // Create incoming payment router.post>( @@ -410,10 +417,14 @@ export class App { createWalletAddressMiddleware(), createValidatorMiddleware< ContextType> - >(resourceServerSpec, { - path: '/incoming-payments', - method: HttpMethod.POST - }), + >( + resourceServerSpec, + { + path: '/incoming-payments', + method: HttpMethod.POST + }, + validatorMiddlewareOptions + ), createTokenIntrospectionMiddleware({ requestType: AccessType.IncomingPayment, requestAction: RequestAction.Create @@ -432,10 +443,14 @@ export class App { createWalletAddressMiddleware(), createValidatorMiddleware< ContextType> - >(resourceServerSpec, { - path: '/incoming-payments', - method: HttpMethod.GET - }), + >( + resourceServerSpec, + { + path: '/incoming-payments', + method: HttpMethod.GET + }, + validatorMiddlewareOptions + ), createTokenIntrospectionMiddleware({ requestType: AccessType.IncomingPayment, requestAction: RequestAction.List @@ -451,10 +466,14 @@ export class App { createWalletAddressMiddleware(), createValidatorMiddleware< ContextType> - >(resourceServerSpec, { - path: '/outgoing-payments', - method: HttpMethod.POST - }), + >( + resourceServerSpec, + { + path: '/outgoing-payments', + method: HttpMethod.POST + }, + validatorMiddlewareOptions + ), createTokenIntrospectionMiddleware({ requestType: AccessType.OutgoingPayment, requestAction: RequestAction.Create @@ -473,10 +492,14 @@ export class App { createWalletAddressMiddleware(), createValidatorMiddleware< ContextType> - >(resourceServerSpec, { - path: '/outgoing-payments', - method: HttpMethod.GET - }), + >( + resourceServerSpec, + { + path: '/outgoing-payments', + method: HttpMethod.GET + }, + validatorMiddlewareOptions + ), createTokenIntrospectionMiddleware({ requestType: AccessType.OutgoingPayment, requestAction: RequestAction.List @@ -492,10 +515,14 @@ export class App { createWalletAddressMiddleware(), createValidatorMiddleware< ContextType> - >(resourceServerSpec, { - path: '/quotes', - method: HttpMethod.POST - }), + >( + resourceServerSpec, + { + path: '/quotes', + method: HttpMethod.POST + }, + validatorMiddlewareOptions + ), createTokenIntrospectionMiddleware({ requestType: AccessType.Quote, requestAction: RequestAction.Create @@ -511,10 +538,14 @@ export class App { createWalletAddressMiddleware(), createValidatorMiddleware< ContextType - >(resourceServerSpec, { - path: '/incoming-payments/{id}', - method: HttpMethod.GET - }), + >( + resourceServerSpec, + { + path: '/incoming-payments/{id}', + method: HttpMethod.GET + }, + validatorMiddlewareOptions + ), createTokenIntrospectionMiddleware({ requestType: AccessType.IncomingPayment, requestAction: RequestAction.Read, @@ -534,7 +565,8 @@ export class App { { path: '/incoming-payments/{id}/complete', method: HttpMethod.POST - } + }, + validatorMiddlewareOptions ), createTokenIntrospectionMiddleware({ requestType: AccessType.IncomingPayment, @@ -554,7 +586,8 @@ export class App { { path: '/outgoing-payments/{id}', method: HttpMethod.GET - } + }, + validatorMiddlewareOptions ), createTokenIntrospectionMiddleware({ requestType: AccessType.OutgoingPayment, @@ -574,7 +607,8 @@ export class App { { path: '/quotes/{id}', method: HttpMethod.GET - } + }, + validatorMiddlewareOptions ), createTokenIntrospectionMiddleware({ requestType: AccessType.Quote, @@ -592,7 +626,8 @@ export class App { { path: '/jwks.json', method: HttpMethod.GET - } + }, + validatorMiddlewareOptions ), async (ctx: WalletAddressKeysContext): Promise => await walletAddressKeyRoutes.getKeysByWalletAddressId(ctx) @@ -604,10 +639,14 @@ export class App { WALLET_ADDRESS_PATH, createWalletAddressMiddleware(), createSpspMiddleware(this.config.spspEnabled), - createValidatorMiddleware(walletAddressServerSpec, { - path: '/', - method: HttpMethod.GET - }), + createValidatorMiddleware( + walletAddressServerSpec, + { + path: '/', + method: HttpMethod.GET + }, + validatorMiddlewareOptions + ), walletAddressRoutes.get ) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index b700495ae3..60be2e62bf 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -317,6 +317,7 @@ export function initIocContainer( container.singleton('walletAddressKeyRoutes', async (deps) => { return createWalletAddressKeyRoutes({ config: await deps.use('config'), + logger: await deps.use('logger'), walletAddressKeyService: await deps.use('walletAddressKeyService'), walletAddressService: await deps.use('walletAddressService') }) diff --git a/packages/backend/src/open_payments/auth/middleware.test.ts b/packages/backend/src/open_payments/auth/middleware.test.ts index b41c605e9c..b7d6256fa3 100644 --- a/packages/backend/src/open_payments/auth/middleware.test.ts +++ b/packages/backend/src/open_payments/auth/middleware.test.ts @@ -1,6 +1,6 @@ import { generateKeyPairSync } from 'crypto' import { faker } from '@faker-js/faker' -import { Client, ActiveTokenInfo } from 'token-introspection' +import { Client, ActiveTokenInfo, TokenInfo } from 'token-introspection' import { v4 as uuid } from 'uuid' import { generateJwk, @@ -28,6 +28,8 @@ import { createWalletAddress } from '../../tests/walletAddress' import { setup } from '../wallet_address/model.test' import { parseLimits } from '../payment/outgoing/limits' import { AccessAction, AccessType } from '@interledger/open-payments' +import { OpenPaymentsServerRouteError } from '../route-errors' +import assert from 'assert' const nock = (global as unknown as { nock: typeof import('nock') }).nock @@ -85,28 +87,37 @@ describe('Auth Middleware', (): void => { }) ctx.request.headers.authorization = '' - const throwSpy = jest.spyOn(ctx, 'throw') await expect(middleware(ctx, next)).resolves.toBeUndefined() - expect(throwSpy).toHaveBeenCalledWith(401, 'Unauthorized') expect(ctx.response.get('WWW-Authenticate')).toBe( `GNAP as_uri=${Config.authServerGrantUrl}` ) expect(next).toHaveBeenCalled() }) - test('throws error for unkonwn errors', async (): Promise => { + test('throws error for unknown errors', async (): Promise => { const middleware = createTokenIntrospectionMiddleware({ requestType: type, requestAction: action, bypassError: true }) - ctx.request.headers.authorization = '' - const error = new Error('Unknown') - ctx.throw = jest.fn().mockImplementation(() => { - throw error - }) as never - await expect(middleware(ctx, next)).rejects.toBe(error) + jest + .spyOn(tokenIntrospectionClient, 'introspect') + .mockResolvedValueOnce({ active: true, access: {} } as TokenInfo) // causes an error other than OpenPaymentsServerRouteError + + expect.assertions(3) + + try { + await middleware(ctx, next) + } catch (err) { + assert(err instanceof Error) + assert(!(err instanceof OpenPaymentsServerRouteError)) + expect(err.message).toBe('tokenInfo.access.find is not a function') + } + + expect(ctx.response.get('WWW-Authenticate')).not.toBe( + `GNAP as_uri=${Config.authServerGrantUrl}` + ) expect(next).not.toHaveBeenCalled() }) }) @@ -114,18 +125,26 @@ describe('Auth Middleware', (): void => { test.each` authorization | description ${undefined} | ${'missing'} - ${'Bearer NOT-GNAP'} | ${'invalid'} - ${'GNAP'} | ${'missing'} + ${'Bearer NOT-GNAP'} | ${'non-GNAP'} + ${'GNAP'} | ${'missing token'} ${'GNAP multiple tokens'} | ${'invalid'} `( - 'returns 401 for $description access token', + 'returns 401 for $description authorization header value', async ({ authorization }): Promise => { const introspectSpy = jest.spyOn(tokenIntrospectionClient, 'introspect') ctx.request.headers.authorization = authorization - await expect(middleware(ctx, next)).resolves.toBeUndefined() + + expect.assertions(5) + try { + await middleware(ctx, next) + } catch (err) { + assert(err instanceof OpenPaymentsServerRouteError) + expect(err.status).toBe(401) + expect(err.message).toEqual( + 'Missing or invalid authorization header value' + ) + } expect(introspectSpy).not.toHaveBeenCalled() - expect(ctx.status).toBe(401) - expect(ctx.message).toEqual('Unauthorized') expect(ctx.response.get('WWW-Authenticate')).toBe( `GNAP as_uri=${Config.authServerGrantUrl}` ) @@ -139,12 +158,19 @@ describe('Auth Middleware', (): void => { .mockImplementation(() => { throw new Error('test error') }) - await expect(middleware(ctx, next)).resolves.toBeUndefined() + + expect.assertions(5) + try { + await middleware(ctx, next) + } catch (err) { + assert(err instanceof OpenPaymentsServerRouteError) + expect(err.status).toBe(401) + expect(err.message).toEqual('Invalid Token') + } + expect(introspectSpy).toHaveBeenCalledWith({ access_token: token }) - expect(ctx.status).toBe(401) - expect(ctx.message).toEqual('Invalid Token') expect(ctx.response.get('WWW-Authenticate')).toBe( `GNAP as_uri=${Config.authServerGrantUrl}` ) @@ -533,18 +559,33 @@ describe('HTTP Signature Middleware', (): void => { test('returns 401 for missing keyid', async (): Promise => { ctx.request.headers['signature-input'] = 'aaaaaaaaaa' - await expect(httpsigMiddleware(ctx, next)).rejects.toMatchObject({ - status: 401, - message: 'Invalid signature input' - }) + expect.assertions(3) + + try { + await httpsigMiddleware(ctx, next) + } catch (err) { + assert(err instanceof OpenPaymentsServerRouteError) + expect(err.message).toBe( + 'Signature validation error: missing keyId in signature input' + ) + expect(err.status).toBe(401) + } + expect(next).not.toHaveBeenCalled() }) test('returns 401 for failed client key request', async (): Promise => { - await expect(httpsigMiddleware(ctx, next)).rejects.toMatchObject({ - status: 401, - message: 'Invalid signature input' - }) + expect.assertions(3) + + try { + await httpsigMiddleware(ctx, next) + } catch (err) { + assert(err instanceof OpenPaymentsServerRouteError) + expect(err.message).toBe( + 'Signature validation error: could not retrieve client keys' + ) + expect(err.status).toBe(401) + } expect(next).not.toHaveBeenCalled() }) @@ -555,10 +596,19 @@ describe('HTTP Signature Middleware', (): void => { keys: [key] }) ctx.request.headers['signature'] = 'aaaaaaaaaa=' - await expect(httpsigMiddleware(ctx, next)).rejects.toMatchObject({ - status: 401, - message: 'Invalid signature' - }) + + expect.assertions(3) + + try { + await httpsigMiddleware(ctx, next) + } catch (err) { + assert(err instanceof OpenPaymentsServerRouteError) + expect(err.message).toBe( + 'Signature validation error: provided signature is invalid' + ) + expect(err.status).toBe(401) + } + expect(next).not.toHaveBeenCalled() scope.done() }) @@ -570,11 +620,20 @@ describe('HTTP Signature Middleware', (): void => { .reply(200, { keys: [key] }) - await expect(httpsigMiddleware(ctx, next)).rejects.toMatchObject({ - status: 401, - message: 'Invalid signature input' - }) + + expect.assertions(3) + + try { + await httpsigMiddleware(ctx, next) + } catch (err) { + assert(err instanceof OpenPaymentsServerRouteError) + expect(err.message).toBe( + 'Signature validation error: could not retrieve client keys' + ) + expect(err.status).toBe(401) + } expect(next).not.toHaveBeenCalled() + scope.done() }) @@ -586,11 +645,20 @@ describe('HTTP Signature Middleware', (): void => { keys: [key] }) ctx.request.headers['content-digest'] = 'aaaaaaaaaa=' - await expect(httpsigMiddleware(ctx, next)).rejects.toMatchObject({ - status: 401, - message: 'Invalid signature' - }) + + expect.assertions(3) + + try { + await httpsigMiddleware(ctx, next) + } catch (err) { + assert(err instanceof OpenPaymentsServerRouteError) + expect(err.message).toBe( + 'Signature validation error: provided signature is invalid' + ) + expect(err.status).toBe(401) + } expect(next).not.toHaveBeenCalled() + scope.done() }) } diff --git a/packages/backend/src/open_payments/auth/middleware.ts b/packages/backend/src/open_payments/auth/middleware.ts index 74266e13c0..bfb3f12243 100644 --- a/packages/backend/src/open_payments/auth/middleware.ts +++ b/packages/backend/src/open_payments/auth/middleware.ts @@ -3,17 +3,22 @@ import { RequestLike, validateSignature } from '@interledger/http-signature-utils' -import Koa, { HttpError } from 'koa' import { Limits, parseLimits } from '../payment/outgoing/limits' import { HttpSigContext, HttpSigWithAuthenticatedStatusContext, WalletAddressContext } from '../../app' -import { AccessAction, AccessType, JWKS } from '@interledger/open-payments' +import { + AccessAction, + AccessType, + JWKS, + OpenPaymentsClientError +} from '@interledger/open-payments' import { TokenInfo } from 'token-introspection' import { isActiveTokenInfo } from 'token-introspection' import { Config } from '../../config/app' +import { OpenPaymentsServerRouteError } from '../route-errors' export type RequestAction = Exclude export const RequestAction: Record = Object.freeze({ @@ -64,7 +69,10 @@ export function createTokenIntrospectionMiddleware({ try { const parts = ctx.request.headers.authorization?.split(' ') if (parts?.length !== 2 || parts[0] !== 'GNAP') { - ctx.throw(401, 'Unauthorized') + throw new OpenPaymentsServerRouteError( + 401, + 'Missing or invalid authorization header value' + ) } const token = parts[1] const tokenIntrospectionClient = await ctx.container.use( @@ -76,10 +84,10 @@ export function createTokenIntrospectionMiddleware({ access_token: token }) } catch (err) { - ctx.throw(401, 'Invalid Token') + throw new OpenPaymentsServerRouteError(401, 'Invalid Token') } if (!isActiveTokenInfo(tokenInfo)) { - ctx.throw(403, 'Inactive Token') + throw new OpenPaymentsServerRouteError(403, 'Inactive Token') } // TODO @@ -115,7 +123,7 @@ export function createTokenIntrospectionMiddleware({ }) if (!access) { - ctx.throw(403, 'Insufficient Grant') + throw new OpenPaymentsServerRouteError(403, 'Insufficient Grant') } ctx.client = tokenInfo.client if ( @@ -130,21 +138,19 @@ export function createTokenIntrospectionMiddleware({ : undefined } } - await next() } catch (err) { - if (bypassError && err instanceof HttpError) { - ctx.set('WWW-Authenticate', `GNAP as_uri=${config.authServerGrantUrl}`) - return await next() + if (!(err instanceof OpenPaymentsServerRouteError)) { + throw err } - if (err instanceof HttpError && err.status === 401) { - ctx.status = 401 - ctx.message = err.message - ctx.set('WWW-Authenticate', `GNAP as_uri=${config.authServerGrantUrl}`) - } else { + ctx.set('WWW-Authenticate', `GNAP as_uri=${config.authServerGrantUrl}`) + + if (!bypassError) { throw err } } + + await next() } } @@ -157,7 +163,7 @@ export const authenticatedStatusMiddleware = async ( await throwIfSignatureInvalid(ctx) ctx.authenticated = true } catch (err) { - if (err instanceof Koa.HttpError && err.status !== 401) { + if (!(err instanceof OpenPaymentsServerRouteError)) { throw err } } @@ -165,48 +171,77 @@ export const authenticatedStatusMiddleware = async ( } export const throwIfSignatureInvalid = async (ctx: HttpSigContext) => { - const keyId = getKeyId(ctx.request.headers['signature-input']) + const keyId = + ctx.request.headers['signature-input'] && + getKeyId(ctx.request.headers['signature-input']) + if (!keyId) { - ctx.throw(401, 'Invalid signature input') + throw new OpenPaymentsServerRouteError( + 401, + 'Signature validation error: missing keyId in signature input', + { client: ctx.client } + ) } // TODO // cache client key(s) - let jwks: JWKS | undefined + let jwks: JWKS try { const openPaymentsClient = await ctx.container.use('openPaymentsClient') jwks = await openPaymentsClient.walletAddress.getKeys({ url: ctx.client }) - } catch (error) { - const logger = await ctx.container.use('logger') - logger.debug( + } catch (err) { + throw new OpenPaymentsServerRouteError( + 401, + 'Signature validation error: could not retrieve client keys', { - error, - client: ctx.client - }, - 'retrieving client key' + client: ctx.client, + keyIdInSignature: keyId, + requestedRoute: `${ctx.client}/jwks.json`, + validationErrorsInRequest: + err instanceof OpenPaymentsClientError + ? err.validationErrors + : undefined + } ) } - const key = jwks?.keys.find((key) => key.kid === keyId) + const key = jwks.keys.find((key) => key.kid === keyId) if (!key) { - ctx.throw(401, 'Invalid signature input') + throw new OpenPaymentsServerRouteError( + 401, + 'Signature validation error: could not find key in list of client keys', + { + client: ctx.client, + keyIdInSignature: keyId, + clientKeys: jwks.keys + } + ) } + + const logger = await ctx.container.use('logger') + + let isValidSignature = false + let requestLike: RequestLike | undefined + try { - if (!(await validateSignature(key, contextToRequestLike(ctx)))) { - ctx.throw(401, 'Invalid signature') - } + requestLike = contextToRequestLike(ctx) + isValidSignature = await validateSignature(key, requestLike) } catch (err) { - if (err instanceof Koa.HttpError) { - throw err - } - const logger = await ctx.container.use('logger') - logger.warn( + logger.error( + { err, requestLike }, + 'Received unhandled eror when trying to validate signature' + ) + } + + if (!isValidSignature) { + throw new OpenPaymentsServerRouteError( + 401, + 'Signature validation error: provided signature is invalid', { - err - }, - 'httpsig error' + keyIdInSignature: keyId, + client: ctx.client + } ) - ctx.throw(401, `Invalid signature`) } } diff --git a/packages/backend/src/open_payments/payment/incoming/routes.test.ts b/packages/backend/src/open_payments/payment/incoming/routes.test.ts index f06d159e71..d6357e0b62 100644 --- a/packages/backend/src/open_payments/payment/incoming/routes.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/routes.test.ts @@ -8,12 +8,7 @@ import { createTestApp, TestContainer } from '../../../tests/app' import { Config, IAppConfig } from '../../../config/app' import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../../..' -import { - AppServices, - CreateContext, - CompleteContext, - ListContext -} from '../../../app' +import { AppServices, CreateContext, CompleteContext } from '../../../app' import { truncateTables } from '../../../tests/tableManager' import { IncomingPayment, IncomingPaymentState } from './model' import { @@ -126,23 +121,6 @@ describe('Incoming Payment Routes', (): void => { list: (ctx) => incomingPaymentRoutes.list(ctx), urlPath: IncomingPayment.urlPath }) - - test('returns 500 for unexpected error', async (): Promise => { - const incomingPaymentService = await deps.use('incomingPaymentService') - jest - .spyOn(incomingPaymentService, 'getWalletAddressPage') - .mockRejectedValueOnce(new Error('unexpected')) - const ctx = setup({ - reqOpts: { - headers: { Accept: 'application/json' } - }, - walletAddress - }) - await expect(incomingPaymentRoutes.list(ctx)).rejects.toMatchObject({ - status: 500, - message: `Error trying to list incoming payments` - }) - }) }) describe('get', (): void => { diff --git a/packages/backend/src/open_payments/payment/incoming/routes.ts b/packages/backend/src/open_payments/payment/incoming/routes.ts index 59319d55b7..6c0993d4d7 100644 --- a/packages/backend/src/open_payments/payment/incoming/routes.ts +++ b/packages/backend/src/open_payments/payment/incoming/routes.ts @@ -1,4 +1,3 @@ -import Koa from 'koa' import { Logger } from 'pino' import { ReadContext, @@ -9,17 +8,13 @@ import { } from '../../../app' import { IAppConfig } from '../../../config/app' import { IncomingPaymentService } from './service' -import { IncomingPayment } from './model' -import { - errorToCode, - errorToMessage, - IncomingPaymentError, - isIncomingPaymentError -} from './errors' +import { errorToCode, errorToMessage, isIncomingPaymentError } from './errors' import { AmountJSON, parseAmount } from '../../amount' import { listSubresource } from '../../wallet_address/routes' import { StreamCredentialsService } from '../../../payment-method/ilp/stream-credentials/service' import { AccessAction } from '@interledger/open-payments' +import { OpenPaymentsServerRouteError } from '../../route-errors' +import { throwIfMissingWalletAddress } from '../../wallet_address/model' interface ServiceDependencies { config: IAppConfig @@ -70,35 +65,46 @@ async function getIncomingPaymentPublic( deps: ServiceDependencies, ctx: ReadContextWithAuthenticatedStatus ) { - try { - const incomingPayment = await deps.incomingPaymentService.get({ - id: ctx.params.id, - client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined - }) - ctx.body = incomingPayment?.toPublicOpenPaymentsType( - deps.config.authServerGrantUrl + const incomingPayment = await deps.incomingPaymentService.get({ + id: ctx.params.id, + client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined + }) + + if (!incomingPayment) { + throw new OpenPaymentsServerRouteError( + 404, + 'Incoming payment does not exist', + { + id: ctx.params.id + } ) - } catch (err) { - const msg = 'Error trying to get incoming payment' - deps.logger.error({ err }, msg) - ctx.throw(500, msg) } + + ctx.body = incomingPayment.toPublicOpenPaymentsType( + deps.config.authServerGrantUrl + ) } async function getIncomingPaymentPrivate( deps: ServiceDependencies, ctx: ReadContextWithAuthenticatedStatus ): Promise { - let incomingPayment: IncomingPayment | undefined - try { - incomingPayment = await deps.incomingPaymentService.get({ - id: ctx.params.id, - client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined - }) - } catch (err) { - ctx.throw(500, 'Error trying to get incoming payment') + const incomingPayment = await deps.incomingPaymentService.get({ + id: ctx.params.id, + client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined + }) + + if (!incomingPayment) { + throw new OpenPaymentsServerRouteError( + 404, + 'Incoming payment does not exist', + { + id: ctx.params.id + } + ) } - if (!incomingPayment || !incomingPayment.walletAddress) return ctx.throw(404) + + throwIfMissingWalletAddress(deps, incomingPayment) const streamCredentials = deps.streamCredentialsService.get(incomingPayment) @@ -135,15 +141,13 @@ async function createIncomingPayment( }) if (isIncomingPaymentError(incomingPaymentOrError)) { - return ctx.throw( + throw new OpenPaymentsServerRouteError( errorToCode[incomingPaymentOrError], errorToMessage[incomingPaymentOrError] ) } - if (!incomingPaymentOrError.walletAddress) { - ctx.throw(404) - } + throwIfMissingWalletAddress(deps, incomingPaymentOrError) ctx.status = 201 const streamCredentials = deps.streamCredentialsService.get( @@ -159,25 +163,18 @@ async function completeIncomingPayment( deps: ServiceDependencies, ctx: CompleteContext ): Promise { - let incomingPaymentOrError: IncomingPayment | IncomingPaymentError - try { - incomingPaymentOrError = await deps.incomingPaymentService.complete( - ctx.params.id - ) - } catch (err) { - ctx.throw(500, 'Error trying to complete incoming payment') - } + const incomingPaymentOrError = await deps.incomingPaymentService.complete( + ctx.params.id + ) if (isIncomingPaymentError(incomingPaymentOrError)) { - return ctx.throw( + throw new OpenPaymentsServerRouteError( errorToCode[incomingPaymentOrError], errorToMessage[incomingPaymentOrError] ) } - if (!incomingPaymentOrError.walletAddress) { - ctx.throw(404) - } + throwIfMissingWalletAddress(deps, incomingPaymentOrError) ctx.body = incomingPaymentOrError.toOpenPaymentsType( incomingPaymentOrError.walletAddress @@ -188,16 +185,9 @@ async function listIncomingPayments( deps: ServiceDependencies, ctx: ListContext ): Promise { - try { - await listSubresource({ - ctx, - getWalletAddressPage: deps.incomingPaymentService.getWalletAddressPage, - toBody: (payment) => payment.toOpenPaymentsType(ctx.walletAddress) - }) - } catch (err) { - if (err instanceof Koa.HttpError) { - throw err - } - ctx.throw(500, 'Error trying to list incoming payments') - } + await listSubresource({ + ctx, + getWalletAddressPage: deps.incomingPaymentService.getWalletAddressPage, + toBody: (payment) => payment.toOpenPaymentsType(ctx.walletAddress) + }) } diff --git a/packages/backend/src/open_payments/payment/outgoing/routes.test.ts b/packages/backend/src/open_payments/payment/outgoing/routes.test.ts index 44627d3a2d..0af9b8008f 100644 --- a/packages/backend/src/open_payments/payment/outgoing/routes.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/routes.test.ts @@ -2,17 +2,12 @@ import { faker } from '@faker-js/faker' import jestOpenAPI from 'jest-openapi' import { Knex } from 'knex' import { v4 as uuid } from 'uuid' - +import assert from 'assert' import { createTestApp, TestContainer } from '../../../tests/app' import { Config, IAppConfig } from '../../../config/app' import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../../..' -import { - AppServices, - CreateContext, - ListContext, - ReadContext -} from '../../../app' +import { AppServices, CreateContext } from '../../../app' import { truncateTables } from '../../../tests/tableManager' import { createAsset } from '../../../tests/asset' import { errorToCode, errorToMessage, OutgoingPaymentError } from './errors' @@ -28,6 +23,7 @@ import { } from '../../wallet_address/model.test' import { createOutgoingPayment } from '../../../tests/outgoingPayment' import { createWalletAddress } from '../../../tests/walletAddress' +import { OpenPaymentsServerRouteError } from '../../route-errors' describe('Outgoing Payment Routes', (): void => { let deps: IocContract @@ -129,38 +125,6 @@ describe('Outgoing Payment Routes', (): void => { }) }) - describe('get', () => { - test('returns 500 for unexpected error', async (): Promise => { - jest - .spyOn(outgoingPaymentService, 'get') - .mockRejectedValueOnce(new Error('unexpected')) - const ctx = setupContext({ - reqOpts: {}, - walletAddress - }) - await expect(outgoingPaymentRoutes.get(ctx)).rejects.toMatchObject({ - status: 500, - message: 'Unhandled error when trying to get outgoing payment' - }) - }) - }) - - describe('list', () => { - test('returns 500 for unexpected error', async (): Promise => { - jest - .spyOn(outgoingPaymentService, 'getWalletAddressPage') - .mockRejectedValueOnce(new Error('unexpected')) - const ctx = setupContext({ - reqOpts: {}, - walletAddress - }) - await expect(outgoingPaymentRoutes.list(ctx)).rejects.toMatchObject({ - status: 500, - message: 'Unhandled error when trying to list outgoing payments' - }) - }) - }) - describe('create', (): void => { const setup = ( options: Omit @@ -255,34 +219,22 @@ describe('Outgoing Payment Routes', (): void => { const createSpy = jest .spyOn(outgoingPaymentService, 'create') .mockResolvedValueOnce(error) - await expect(outgoingPaymentRoutes.create(ctx)).rejects.toMatchObject({ - message: errorToMessage[error], - status: errorToCode[error] - }) + + expect.assertions(3) + + try { + await outgoingPaymentRoutes.create(ctx) + } catch (err) { + assert(err instanceof OpenPaymentsServerRouteError) + expect(err.message).toBe(errorToMessage[error]) + expect(err.status).toBe(errorToCode[error]) + } + expect(createSpy).toHaveBeenCalledWith({ walletAddressId: walletAddress.id, quoteId }) } ) - - test('returns 500 on unhandled error', async (): Promise => { - const quoteId = uuid() - const ctx = setup({ - quoteId: `${baseUrl}/quotes/${quoteId}` - }) - const createSpy = jest - .spyOn(outgoingPaymentService, 'create') - .mockRejectedValueOnce(new Error('Some error')) - - await expect(outgoingPaymentRoutes.create(ctx)).rejects.toMatchObject({ - message: 'Unhandled error when trying to create outgoing payment', - status: 500 - }) - expect(createSpy).toHaveBeenCalledWith({ - walletAddressId: walletAddress.id, - quoteId - }) - }) }) }) diff --git a/packages/backend/src/open_payments/payment/outgoing/routes.ts b/packages/backend/src/open_payments/payment/outgoing/routes.ts index 8b78b6797f..e061b1b2e0 100644 --- a/packages/backend/src/open_payments/payment/outgoing/routes.ts +++ b/packages/backend/src/open_payments/payment/outgoing/routes.ts @@ -1,21 +1,19 @@ -import Koa from 'koa' import { Logger } from 'pino' import { ReadContext, CreateContext, ListContext } from '../../../app' import { IAppConfig } from '../../../config/app' import { OutgoingPaymentService } from './service' -import { - isOutgoingPaymentError, - errorToCode, - errorToMessage, - OutgoingPaymentError -} from './errors' +import { isOutgoingPaymentError, errorToCode, errorToMessage } from './errors' import { OutgoingPayment } from './model' import { listSubresource } from '../../wallet_address/routes' import { AccessAction, OutgoingPayment as OpenPaymentsOutgoingPayment } from '@interledger/open-payments' -import { WalletAddress } from '../../wallet_address/model' +import { + WalletAddress, + throwIfMissingWalletAddress +} from '../../wallet_address/model' +import { OpenPaymentsServerRouteError } from '../../route-errors' interface ServiceDependencies { config: IAppConfig @@ -48,21 +46,23 @@ async function getOutgoingPayment( deps: ServiceDependencies, ctx: ReadContext ): Promise { - let outgoingPayment: OutgoingPayment | undefined - try { - outgoingPayment = await deps.outgoingPaymentService.get({ - id: ctx.params.id, - client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined - }) - } catch (err) { - const errorMessage = 'Unhandled error when trying to get outgoing payment' - deps.logger.error( - { err, id: ctx.params.id, walletAddressId: ctx.walletAddress.id }, - errorMessage + const outgoingPayment = await deps.outgoingPaymentService.get({ + id: ctx.params.id, + client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined + }) + + if (!outgoingPayment) { + throw new OpenPaymentsServerRouteError( + 404, + 'Outgoing payment does not exist', + { + id: ctx.params.id + } ) - return ctx.throw(500, errorMessage) } - if (!outgoingPayment || !outgoingPayment.walletAddress) return ctx.throw(404) + + throwIfMissingWalletAddress(deps, outgoingPayment) + ctx.body = outgoingPaymentToBody( outgoingPayment.walletAddress, outgoingPayment @@ -84,65 +84,46 @@ async function createOutgoingPayment( const quoteUrlParts = body.quoteId.split('/') const quoteId = quoteUrlParts.pop() || quoteUrlParts.pop() // handle trailing slash if (!quoteId) { - return ctx.throw(400, 'invalid quoteId') - } - - let outgoingPaymentOrError: OutgoingPayment | OutgoingPaymentError - - try { - outgoingPaymentOrError = await deps.outgoingPaymentService.create({ - walletAddressId: ctx.walletAddress.id, - quoteId, - metadata: body.metadata, - client: ctx.client, - grant: ctx.grant - }) - } catch (err) { - const errorMessage = - 'Unhandled error when trying to create outgoing payment' - deps.logger.error( - { err, quoteId, walletAddressId: ctx.walletAddress.id }, - errorMessage + throw new OpenPaymentsServerRouteError( + 400, + 'Invalid quote id trying to create outgoing payment', + { requestBody: body } ) - return ctx.throw(500, errorMessage) } + const outgoingPaymentOrError = await deps.outgoingPaymentService.create({ + walletAddressId: ctx.walletAddress.id, + quoteId, + metadata: body.metadata, + client: ctx.client, + grant: ctx.grant + }) + if (isOutgoingPaymentError(outgoingPaymentOrError)) { - return ctx.throw( + throw new OpenPaymentsServerRouteError( errorToCode[outgoingPaymentOrError], errorToMessage[outgoingPaymentOrError] ) } + + throwIfMissingWalletAddress(deps, outgoingPaymentOrError) + ctx.status = 201 - ctx.body = outgoingPaymentToBody(ctx.walletAddress, outgoingPaymentOrError) + ctx.body = outgoingPaymentToBody( + outgoingPaymentOrError.walletAddress, + outgoingPaymentOrError + ) } async function listOutgoingPayments( deps: ServiceDependencies, ctx: ListContext ): Promise { - try { - await listSubresource({ - ctx, - getWalletAddressPage: deps.outgoingPaymentService.getWalletAddressPage, - toBody: (payment) => outgoingPaymentToBody(ctx.walletAddress, payment) - }) - } catch (err) { - if (err instanceof Koa.HttpError) { - throw err - } - - const errorMessage = 'Unhandled error when trying to list outgoing payments' - deps.logger.error( - { - err, - request: ctx.request.query, - walletAddressId: ctx.walletAddress.id - }, - errorMessage - ) - return ctx.throw(500, errorMessage) - } + await listSubresource({ + ctx, + getWalletAddressPage: deps.outgoingPaymentService.getWalletAddressPage, + toBody: (payment) => outgoingPaymentToBody(ctx.walletAddress, payment) + }) } function outgoingPaymentToBody( diff --git a/packages/backend/src/open_payments/quote/routes.test.ts b/packages/backend/src/open_payments/quote/routes.test.ts index b7cf22f0af..422759bd73 100644 --- a/packages/backend/src/open_payments/quote/routes.test.ts +++ b/packages/backend/src/open_payments/quote/routes.test.ts @@ -151,17 +151,6 @@ describe('Quote Routes', (): void => { }) }) - test('returns 500 on error', async (): Promise => { - jest - .spyOn(quoteService, 'create') - .mockRejectedValueOnce(new Error('unexpected')) - const ctx = setup({}) - await expect(quoteRoutes.create(ctx)).rejects.toMatchObject({ - message: 'Error trying to create quote', - status: 500 - }) - }) - describe.each` client | description ${faker.internet.url({ appendSlash: false })} | ${'client'} diff --git a/packages/backend/src/open_payments/quote/routes.ts b/packages/backend/src/open_payments/quote/routes.ts index f45273e84b..35119f9653 100644 --- a/packages/backend/src/open_payments/quote/routes.ts +++ b/packages/backend/src/open_payments/quote/routes.ts @@ -7,7 +7,11 @@ import { isQuoteError, errorToCode, errorToMessage } from './errors' import { Quote } from './model' import { AmountJSON, parseAmount } from '../amount' import { Quote as OpenPaymentsQuote } from '@interledger/open-payments' -import { WalletAddress } from '../wallet_address/model' +import { + WalletAddress, + throwIfMissingWalletAddress +} from '../wallet_address/model' +import { OpenPaymentsServerRouteError } from '../route-errors' interface ServiceDependencies { config: IAppConfig @@ -39,7 +43,15 @@ async function getQuote( id: ctx.params.id, client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined }) - if (!quote || !quote.walletAddress) return ctx.throw(404) + + if (!quote) { + throw new OpenPaymentsServerRouteError(404, 'Quote does not exist', { + id: ctx.params.id + }) + } + + throwIfMissingWalletAddress(deps, quote) + ctx.body = quoteToBody(quote.walletAddress, quote) } @@ -72,25 +84,32 @@ async function createQuote( client: ctx.client, method: body.method } - if (body.debitAmount) options.debitAmount = parseAmount(body.debitAmount) - if (body.receiveAmount) - options.receiveAmount = parseAmount(body.receiveAmount) + try { - const quoteOrErr = await deps.quoteService.create(options) + if (body.debitAmount) options.debitAmount = parseAmount(body.debitAmount) + if (body.receiveAmount) + options.receiveAmount = parseAmount(body.receiveAmount) + } catch (err) { + throw new OpenPaymentsServerRouteError( + 400, + 'Could not parse amounts when creating quote', + { requestBody: body } + ) + } - if (isQuoteError(quoteOrErr)) { - throw quoteOrErr - } + const quoteOrErr = await deps.quoteService.create(options) - ctx.status = 201 - ctx.body = quoteToBody(ctx.walletAddress, quoteOrErr) - } catch (err) { - if (isQuoteError(err)) { - return ctx.throw(errorToCode[err], errorToMessage[err]) - } - deps.logger.debug({ error: err instanceof Error && err.message }) - ctx.throw(500, 'Error trying to create quote') + if (isQuoteError(quoteOrErr)) { + throw new OpenPaymentsServerRouteError( + errorToCode[quoteOrErr], + errorToMessage[quoteOrErr] + ) } + + throwIfMissingWalletAddress(deps, quoteOrErr) + + ctx.status = 201 + ctx.body = quoteToBody(quoteOrErr.walletAddress, quoteOrErr) } function quoteToBody( diff --git a/packages/backend/src/open_payments/route-errors.test.ts b/packages/backend/src/open_payments/route-errors.test.ts new file mode 100644 index 0000000000..c837c2a0e5 --- /dev/null +++ b/packages/backend/src/open_payments/route-errors.test.ts @@ -0,0 +1,89 @@ +import { AppContext, AppServices } from '../app' +import { createContext } from '../tests/context' +import { + OpenPaymentsServerRouteError, + openPaymentsServerErrorMiddleware +} from './route-errors' +import { IocContract } from '@adonisjs/fold' +import { initIocContainer } from '..' +import { Config } from '../config/app' +import { OpenAPIValidatorMiddlewareError } from '@interledger/openapi' + +describe('openPaymentServerErrorMiddleware', (): void => { + let deps: IocContract + let ctx: AppContext + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + }) + + beforeEach(async (): Promise => { + ctx = createContext( + { + headers: { + accept: 'application/json' + } + }, + {} + ) + + ctx.container = deps + }) + + test('handles OpenPaymentsServerRouteError error', async (): Promise => { + const error = new OpenPaymentsServerRouteError(401, 'Some error') + const next = jest.fn().mockImplementationOnce(() => { + throw error + }) + + const ctxThrowSpy = jest.spyOn(ctx, 'throw') + + await expect( + openPaymentsServerErrorMiddleware(ctx, next) + ).rejects.toMatchObject({ + status: error.status, + message: error.message + }) + + expect(ctxThrowSpy).toHaveBeenCalledWith(error.status, error.message) + expect(next).toHaveBeenCalledTimes(1) + }) + + test('handles OpenAPIValidatorMiddlewareError error', async (): Promise => { + const error = new OpenAPIValidatorMiddlewareError('Validation error', 400) + const next = jest.fn().mockImplementationOnce(() => { + throw error + }) + + const ctxThrowSpy = jest.spyOn(ctx, 'throw') + + await expect( + openPaymentsServerErrorMiddleware(ctx, next) + ).rejects.toMatchObject({ + status: error.status, + message: error.message + }) + + expect(ctxThrowSpy).toHaveBeenCalledWith(error.status, error.message) + expect(next).toHaveBeenCalledTimes(1) + }) + + test('handles unspecified error', async (): Promise => { + const error = new Error('Some unspecified error') + const next = jest.fn().mockImplementationOnce(() => { + throw error + }) + + const ctxThrowSpy = jest.spyOn(ctx, 'throw') + + await expect( + openPaymentsServerErrorMiddleware(ctx, next) + ).rejects.toMatchObject({ + status: 500, + message: 'Internal Server Error' + }) + + expect(ctxThrowSpy).toHaveBeenCalledWith(500) + expect(next).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/backend/src/open_payments/route-errors.ts b/packages/backend/src/open_payments/route-errors.ts new file mode 100644 index 0000000000..702c9cf184 --- /dev/null +++ b/packages/backend/src/open_payments/route-errors.ts @@ -0,0 +1,66 @@ +import { AppContext } from '../app' +import { OpenAPIValidatorMiddlewareError } from '@interledger/openapi' + +export class OpenPaymentsServerRouteError extends Error { + public status: number + public details?: Record + + constructor( + status: number, + message: string, + details?: Record + ) { + super(message) + this.name = 'OpenPaymentsServerRouteError' + this.status = status + this.details = details + } +} + +export async function openPaymentsServerErrorMiddleware( + ctx: AppContext, + next: () => Promise +) { + try { + await next() + } catch (err) { + const logger = await ctx.container.use('logger') + + const baseLog = { + method: ctx.req.method, + path: ctx.path + } + + if (err instanceof OpenPaymentsServerRouteError) { + logger.info( + { + ...baseLog, + message: err.message, + details: err.details, + status: err.status, + requestBody: ctx.request.body + }, + 'Received error when handling Open Payments request' + ) + ctx.throw(err.status, err.message) + } else if (err instanceof OpenAPIValidatorMiddlewareError) { + const finalStatus = err.status || 400 + + logger.info( + { + ...baseLog, + message: err.message, + status: finalStatus + }, + 'Received OpenAPI validation error when handling Open Payments request' + ) + ctx.throw(finalStatus, err.message) + } + + logger.error( + { ...baseLog, err }, + 'Received unhandled error in Open Payments request' + ) + ctx.throw(500) + } +} diff --git a/packages/backend/src/open_payments/wallet_address/key/routes.test.ts b/packages/backend/src/open_payments/wallet_address/key/routes.test.ts index d43c4c9244..69ac364e2c 100644 --- a/packages/backend/src/open_payments/wallet_address/key/routes.test.ts +++ b/packages/backend/src/open_payments/wallet_address/key/routes.test.ts @@ -119,7 +119,7 @@ describe('Wallet Address Keys Routes', (): void => { await expect( walletAddressKeyRoutes.getKeysByWalletAddressId(ctx) - ).rejects.toHaveProperty('status', 404) + ).rejects.toThrow(/Could not get wallet address keys./) }) }) }) diff --git a/packages/backend/src/open_payments/wallet_address/key/routes.ts b/packages/backend/src/open_payments/wallet_address/key/routes.ts index e7e54aa4ec..65b3915a1c 100644 --- a/packages/backend/src/open_payments/wallet_address/key/routes.ts +++ b/packages/backend/src/open_payments/wallet_address/key/routes.ts @@ -4,11 +4,13 @@ import { WalletAddressKeysContext } from '../../../app' import { IAppConfig } from '../../../config/app' import { WalletAddressService } from '../service' import { WalletAddressKeyService } from './service' +import { Logger } from 'pino' interface ServiceDependencies { walletAddressKeyService: WalletAddressKeyService walletAddressService: WalletAddressService config: IAppConfig + logger: Logger jwk: JWK } @@ -50,6 +52,16 @@ export async function getKeysByWalletAddressId( keys: [deps.jwk] } } else { - return ctx.throw(404) + const errorMessage = + 'Could not get wallet address keys. It is possible there is a wallet address configuration error for this Rafiki instance.' + deps.logger.error( + { + requestedWalletAddress: ctx.walletAddressUrl, + configuredWalletAddressUrl: deps.config.walletAddressUrl + }, + errorMessage + ) + + throw new Error(errorMessage) } } diff --git a/packages/backend/src/open_payments/wallet_address/model.test.ts b/packages/backend/src/open_payments/wallet_address/model.test.ts index f14164e652..7026f4dfa8 100644 --- a/packages/backend/src/open_payments/wallet_address/model.test.ts +++ b/packages/backend/src/open_payments/wallet_address/model.test.ts @@ -10,7 +10,8 @@ import { ListOptions, WalletAddressEventError, WalletAddressEventType, - WalletAddressEvent + WalletAddressEvent, + throwIfMissingWalletAddress } from './model' import { Grant } from '../auth/middleware' import { @@ -31,6 +32,8 @@ import { IocContract } from '@adonisjs/fold' import assert from 'assert' import { ReadContextWithAuthenticatedStatus } from '../payment/incoming/routes' import { Knex } from 'knex' +import { OpenPaymentsServerRouteError } from '../route-errors' +import { createIncomingPayment } from '../../tests/incomingPayment' export interface SetupOptions { reqOpts: httpMocks.RequestOptions @@ -264,10 +267,13 @@ export const getRouteTests = ({ expect(ctx.response).toSatisfyApiSpec() expect(ctx.body).toEqual(getBody(expectedMatch)) } else { - await expect(get(ctx)).rejects.toMatchObject({ - status: 404, - message: 'Not Found' - }) + expect.assertions(1) + try { + await get(ctx) + } catch (err) { + assert(err instanceof OpenPaymentsServerRouteError) + expect(err.status).toBe(404) + } } }, // tests walletAddressId / client filtering @@ -345,10 +351,15 @@ export const getRouteTests = ({ walletAddress: await getWalletAddress(), accessAction: AccessAction.ListAll }) - await expect(list(ctx)).rejects.toMatchObject({ - status: 400, - message - }) + + expect.assertions(2) + try { + await list(ctx) + } catch (error) { + assert(error instanceof OpenPaymentsServerRouteError) + expect(error.status).toBe(400) + expect(error.message).toBe(message) + } } ) }) @@ -453,3 +464,51 @@ describe('Models', (): void => { }) }) }) + +describe('throwIfMissingWalletAddress', (): void => { + let deps: IocContract + let appContainer: TestContainer + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + test('throws if missing wallet address on subresource', async () => { + const logger = await deps.use('logger') + + const walletAddress = await createWalletAddress(deps) + const incomingPayment = await createIncomingPayment(deps, { + walletAddressId: walletAddress.id + }) + + delete incomingPayment.walletAddress + + expect(() => + throwIfMissingWalletAddress({ logger }, incomingPayment) + ).toThrow( + 'IncomingPayment does not have wallet address. This should be investigated.' + ) + }) + + test('does not throw if existing wallet address on subresource', async () => { + const logger = await deps.use('logger') + + const walletAddress = await createWalletAddress(deps) + const incomingPayment = await createIncomingPayment(deps, { + walletAddressId: walletAddress.id + }) + + expect(() => + throwIfMissingWalletAddress({ logger }, incomingPayment) + ).not.toThrow() + }) +}) diff --git a/packages/backend/src/open_payments/wallet_address/model.ts b/packages/backend/src/open_payments/wallet_address/model.ts index 9a96527e98..eb2a07f6d0 100644 --- a/packages/backend/src/open_payments/wallet_address/model.ts +++ b/packages/backend/src/open_payments/wallet_address/model.ts @@ -7,6 +7,7 @@ import { BaseModel, Pagination, SortOrder } from '../../shared/baseModel' import { WebhookEvent } from '../../webhook/model' import { WalletAddressKey } from '../../open_payments/wallet_address/key/model' import { AmountJSON } from '../amount' +import { Logger } from 'pino' export class WalletAddress extends BaseModel @@ -229,3 +230,15 @@ export abstract class WalletAddressSubresource extends BaseModel { QueryBuilderType!: SubresourceQueryBuilder static QueryBuilder = SubresourceQueryBuilder } + +export function throwIfMissingWalletAddress( + deps: { logger: Logger }, + resource: WalletAddressSubresource +): asserts resource is WalletAddressSubresource & + Required> { + if (!resource.walletAddress) { + const errorMessage = `${resource.$modelClass.name} does not have wallet address. This should be investigated.` + deps.logger.error({ id: resource.id }, errorMessage) + throw new Error(errorMessage) + } +} diff --git a/packages/backend/src/open_payments/wallet_address/routes.ts b/packages/backend/src/open_payments/wallet_address/routes.ts index 2e5869aa00..734f854fad 100644 --- a/packages/backend/src/open_payments/wallet_address/routes.ts +++ b/packages/backend/src/open_payments/wallet_address/routes.ts @@ -6,6 +6,7 @@ import { getPageInfo, parsePaginationQueryParameters } from '../../shared/pagination' +import { OpenPaymentsServerRouteError } from '../route-errors' interface ServiceDependencies { authServer: string @@ -30,7 +31,13 @@ export async function getWalletAddress( ctx: WalletAddressContext ): Promise { if (!ctx.walletAddress) { - return ctx.throw(404) + throw new OpenPaymentsServerRouteError( + 404, + 'Wallet address does not exist', + { + walletAddressUrl: ctx.walletAddressUrl + } + ) } ctx.body = ctx.walletAddress.toOpenPaymentsType({ @@ -52,9 +59,15 @@ export const listSubresource = async ({ }: ListSubresourceOptions) => { if (ctx.request.query.last) { if (ctx.request.query.first) { - ctx.throw(400, 'first and last are mutually exclusive') + throw new OpenPaymentsServerRouteError( + 400, + 'first and last are mutually exclusive', + { queryParams: ctx.request.query } + ) } else if (!ctx.request.query.cursor) { - ctx.throw(400, 'last requires cursor') + throw new OpenPaymentsServerRouteError(400, 'last requires cursor', { + queryParams: ctx.request.query + }) } } const pagination = parsePaginationQueryParameters(ctx.request.query) diff --git a/packages/token-introspection/package.json b/packages/token-introspection/package.json index 24dceee9fd..1472d7fe4b 100644 --- a/packages/token-introspection/package.json +++ b/packages/token-introspection/package.json @@ -26,7 +26,7 @@ }, "dependencies": { "@interledger/open-payments": "6.8.0", - "@interledger/openapi": "1.2.1", + "@interledger/openapi": "2.0.1", "axios": "^1.6.8", "pino": "^8.19.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33ea22855f..af15721a31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -281,8 +281,8 @@ importers: specifier: 6.8.0 version: 6.8.0 '@interledger/openapi': - specifier: 1.2.1 - version: 1.2.1 + specifier: 2.0.1 + version: 2.0.1 '@interledger/pay': specifier: 0.4.0-alpha.9 version: 0.4.0-alpha.9 @@ -608,8 +608,8 @@ importers: specifier: 6.8.0 version: 6.8.0 '@interledger/openapi': - specifier: 1.2.1 - version: 1.2.1 + specifier: 2.0.1 + version: 2.0.1 axios: specifier: ^1.6.8 version: 1.6.8 @@ -3880,7 +3880,6 @@ packages: openapi-types: 12.1.3 transitivePeerDependencies: - supports-color - dev: true /@interledger/pay@0.4.0-alpha.9: resolution: {integrity: sha512-ScT+hsAFBjpSy68VncSa6wW+VidgviKQE9W9lyiOBCrXfnrwrTdycEXWOG9ShoAYXpA3/FG/dYO9eImAPO5Pzg==} @@ -4104,7 +4103,7 @@ packages: dependencies: '@jridgewell/trace-mapping': 0.3.23 callsites: 3.1.0 - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 dev: true /@jest/test-result@29.7.0: @@ -5533,10 +5532,6 @@ packages: /@types/node@18.11.9: resolution: {integrity: sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==} - /@types/node@18.18.5: - resolution: {integrity: sha512-4slmbtwV59ZxitY4ixUZdy1uRLf9eSIvBWPQxNjhHYWEtn0FryfKpyS2cvADYXTayWdKEIsJengncrVvkI4I6A==} - dev: true - /@types/node@18.19.19: resolution: {integrity: sha512-qqV6hSy9zACEhQUy5CEGeuXAZN0fNjqLWRIvOXOwdFYhFoKBiY08VKR5kgchr90+TitLVhpUEb54hk4bYaArUw==} dependencies: @@ -5548,12 +5543,6 @@ packages: dependencies: undici-types: 5.26.5 - /@types/node@20.10.6: - resolution: {integrity: sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==} - dependencies: - undici-types: 5.26.5 - dev: true - /@types/node@20.12.7: resolution: {integrity: sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==} dependencies: @@ -9748,6 +9737,9 @@ packages: /graceful-fs@4.2.10: resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + /graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + /grapheme-splitter@1.0.4: resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} dev: true @@ -11141,7 +11133,7 @@ packages: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 18.18.5 + '@types/node': 20.12.7 jest-mock: 29.7.0 jest-util: 29.7.0 dev: true @@ -11555,7 +11547,7 @@ packages: resolution: {integrity: sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==} hasBin: true dependencies: - minimist: 1.2.6 + minimist: 1.2.8 dev: true /json5@2.2.3: @@ -11571,7 +11563,7 @@ packages: dependencies: universalify: 2.0.0 optionalDependencies: - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 /jsonify@0.0.0: resolution: {integrity: sha512-trvBk1ki43VZptdBI5rIlG4YOzyeH/WefQt5rj1grasPn4iiZWKet8nkgc4GlsAylaztn0qZfUYOiTsASJFdNA==} @@ -11799,7 +11791,7 @@ packages: resolution: {integrity: sha512-OfCBkGEw4nN6JLtgRidPX6QxjBQGQf72q3si2uvqyFEMbycSFFHwAZeXx6cJgFM9wmLrf9zBwCP3Ivqa+LLZPw==} engines: {node: '>=6'} dependencies: - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 js-yaml: 3.14.1 pify: 4.0.1 strip-bom: 3.0.0 @@ -12955,10 +12947,10 @@ packages: /minimist@1.2.6: resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==} + dev: true /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: true /minipass-collect@1.0.2: resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} @@ -13191,7 +13183,7 @@ packages: engines: {node: '>=14'} dependencies: '@types/express': 4.17.21 - '@types/node': 20.10.6 + '@types/node': 20.12.7 accepts: 1.3.8 content-disposition: 0.5.4 depd: 1.1.2 @@ -13954,7 +13946,7 @@ packages: fast-safe-stringify: 2.1.1 help-me: 5.0.0 joycon: 3.1.1 - minimist: 1.2.6 + minimist: 1.2.8 on-exit-leak-free: 2.1.0 pino-abstract-transport: 1.1.0 pump: 3.0.0 @@ -14267,7 +14259,7 @@ packages: detect-libc: 2.0.2 expand-template: 2.0.3 github-from-package: 0.0.0 - minimist: 1.2.6 + minimist: 1.2.8 mkdirp-classic: 0.5.3 napi-build-utils: 1.0.2 node-abi: 3.47.0 @@ -14565,7 +14557,7 @@ packages: dependencies: deep-extend: 0.6.0 ini: 1.3.8 - minimist: 1.2.6 + minimist: 1.2.8 strip-json-comments: 2.0.1 dev: false optional: true