diff --git a/packages/api/config/signed-api.example.json b/packages/api/config/signed-api.example.json index ead93b3c..c56a5852 100644 --- a/packages/api/config/signed-api.example.json +++ b/packages/api/config/signed-api.example.json @@ -9,5 +9,5 @@ "delaySeconds": 15 } ], - "allowedAirnodes": ["0x8A791620dd6260079BF849Dc5567aDC3F2FdC318"] + "allowedAirnodes": ["0xbF3137b0a7574563a23a8fC8badC6537F98197CC"] } diff --git a/packages/api/package.json b/packages/api/package.json index 046e734b..8f83a2de 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -9,7 +9,7 @@ "scripts": { "build": "tsc --project tsconfig.build.json", "clean": "rm -rf coverage dist", - "dev": "nodemon --ext ts,js,json,env --exec \"pnpm ts-node src/dev-server.ts\"", + "dev": "nodemon --ext ts,js,json,env --exec \"DEV_SERVER_PORT=${DEV_SERVER_PORT:-8090} pnpm ts-node src/dev-server.ts\"", "docker:build": "docker build --target api --tag api3/signed-api:latest ../../", "docker:run": "docker run --publish 8090:80 -it --init --volume $(pwd)/config:/app/config --env-file .env --rm api3/signed-api:latest", "eslint:check": "eslint . --ext .js,.ts --max-warnings 0", diff --git a/packages/api/src/dev-server.ts b/packages/api/src/dev-server.ts index 1249bcd4..f9909b36 100644 --- a/packages/api/src/dev-server.ts +++ b/packages/api/src/dev-server.ts @@ -4,7 +4,7 @@ import { fetchAndCacheConfig } from './config'; import { logger } from './logger'; import { DEFAULT_PORT, startServer } from './server'; -const portSchema = z.number().int().positive(); +const portSchema = z.coerce.number().int().positive(); const startDevServer = async () => { const config = await fetchAndCacheConfig(); diff --git a/packages/api/src/handlers.test.ts b/packages/api/src/handlers.test.ts index 211a432e..2a104623 100644 --- a/packages/api/src/handlers.test.ts +++ b/packages/api/src/handlers.test.ts @@ -1,3 +1,4 @@ +import { ethers } from 'ethers'; import { omit } from 'lodash'; import { getMockedConfig } from '../test/fixtures'; @@ -27,9 +28,11 @@ describe(batchInsertData.name, () => { expect(result).toStrictEqual({ body: JSON.stringify({ message: 'Unable to recover signer address', - detail: - 'signature missing v and recoveryParam (argument="signature", value="0xInvalid", code=INVALID_ARGUMENT, version=bytes/5.7.0)', - extra: invalidData, + context: { + detail: + 'signature missing v and recoveryParam (argument="signature", value="0xInvalid", code=INVALID_ARGUMENT, version=bytes/5.7.0)', + signedData: invalidData, + }, }), headers: { 'access-control-allow-methods': '*', @@ -45,12 +48,18 @@ describe(batchInsertData.name, () => { const config = getMockedConfig(); config.allowedAirnodes = []; jest.spyOn(configModule, 'getConfig').mockReturnValue(config); - const batchData = [await createSignedData()]; + const airnodeWallet = ethers.Wallet.fromMnemonic( + 'wear lawsuit design cry express certain knock shrug credit wealth update walk' + ); + const batchData = [await createSignedData({ airnodeWallet })]; const result = await batchInsertData(batchData); expect(result).toStrictEqual({ - body: JSON.stringify({ message: 'Unauthorized Airnode address' }), + body: JSON.stringify({ + message: 'Unauthorized Airnode address', + context: { airnodeAddress: '0x05E4B3cb2A6875bdD4CCb867B6aA833934EDDCBf' }, + }), headers: { 'access-control-allow-methods': '*', 'access-control-allow-origin': '*', @@ -103,7 +112,10 @@ describe(batchInsertData.name, () => { const result = await batchInsertData(batchData); expect(result).toStrictEqual({ - body: JSON.stringify({ message: 'Request timestamp is too far in the future', extra: batchData[0] }), + body: JSON.stringify({ + message: 'Request timestamp is too far in the future', + context: { signedData: batchData[0] }, + }), headers: { 'access-control-allow-methods': '*', 'access-control-allow-origin': '*', diff --git a/packages/api/src/handlers.ts b/packages/api/src/handlers.ts index 505bfcca..5892509a 100644 --- a/packages/api/src/handlers.ts +++ b/packages/api/src/handlers.ts @@ -18,11 +18,9 @@ import { generateErrorResponse, isBatchUnique } from './utils'; export const batchInsertData = async (requestBody: unknown): Promise => { const goValidateSchema = await go(async () => batchSignedDataSchema.parseAsync(requestBody)); if (!goValidateSchema.success) { - return generateErrorResponse( - 400, - 'Invalid request, body must fit schema for batch of signed data', - goValidateSchema.error.message - ); + return generateErrorResponse(400, 'Invalid request, body must fit schema for batch of signed data', { + detail: goValidateSchema.error.message, + }); } // Ensure that the batch of signed that comes from a whitelisted Airnode. @@ -31,7 +29,10 @@ export const batchInsertData = async (requestBody: unknown): Promise allowedAirnodes.includes(signedData.airnode)) ) { - return generateErrorResponse(403, 'Unauthorized Airnode address'); + const disallowedAirnodeAddress = goValidateSchema.data.find( + (signedData) => !allowedAirnodes.includes(signedData.airnode) + )!.airnode; + return generateErrorResponse(403, 'Unauthorized Airnode address', { airnodeAddress: disallowedAirnodeAddress }); } // Ensure there is at least one signed data to push @@ -46,30 +47,31 @@ export const batchInsertData = async (requestBody: unknown): Promise Math.floor(Date.now() / 1000) + 60 * 60) { - return generateErrorResponse(400, 'Request timestamp is too far in the future', undefined, signedData); + return generateErrorResponse(400, 'Request timestamp is too far in the future', { signedData }); } const goRecoverSigner = goSync(() => recoverSignerAddress(signedData)); if (!goRecoverSigner.success) { - return generateErrorResponse(400, 'Unable to recover signer address', goRecoverSigner.error.message, signedData); + return generateErrorResponse(400, 'Unable to recover signer address', { + detail: goRecoverSigner.error.message, + signedData, + }); } if (signedData.airnode !== goRecoverSigner.data) { - return generateErrorResponse(400, 'Signature is invalid', undefined, signedData); + return generateErrorResponse(400, 'Signature is invalid', { signedData }); } const goDeriveBeaconId = goSync(() => deriveBeaconId(signedData.airnode, signedData.templateId)); if (!goDeriveBeaconId.success) { - return generateErrorResponse( - 400, - 'Unable to derive beaconId by given airnode and templateId', - goDeriveBeaconId.error.message, - signedData - ); + return generateErrorResponse(400, 'Unable to derive beaconId by given airnode and templateId', { + detail: goDeriveBeaconId.error.message, + signedData, + }); } if (signedData.beaconId !== goDeriveBeaconId.data) { - return generateErrorResponse(400, 'beaconId is invalid', undefined, signedData); + return generateErrorResponse(400, 'beaconId is invalid', { signedData }); } return null; @@ -96,7 +98,9 @@ export const batchInsertData = async (requestBody: unknown): Promise putAll(newSignedData)); if (!goBatchWriteDb.success) { - return generateErrorResponse(500, 'Unable to send batch of signed data to database', goBatchWriteDb.error.message); + return generateErrorResponse(500, 'Unable to send batch of signed data to database', { + detail: goBatchWriteDb.error.message, + }); } // Prune the cache with the data that is too old (no endpoint will ever return it) @@ -104,7 +108,7 @@ export const batchInsertData = async (requestBody: unknown): Promise prune(newSignedData, maxIgnoreAfterTimestamp)); if (!goPruneCache.success) { - return generateErrorResponse(500, 'Unable to remove outdated cache data', goPruneCache.error.message); + return generateErrorResponse(500, 'Unable to remove outdated cache data', { detail: goPruneCache.error.message }); } return { @@ -130,13 +134,13 @@ export const getData = async (airnodeAddress: string, delaySeconds: number): Pro const { allowedAirnodes } = getConfig(); if (allowedAirnodes !== '*' && !allowedAirnodes.includes(airnodeAddress)) { - return generateErrorResponse(403, 'Unauthorized Airnode address'); + return generateErrorResponse(403, 'Unauthorized Airnode address', { airnodeAddress }); } const ignoreAfterTimestamp = Math.floor(Date.now() / 1000 - delaySeconds); const goReadDb = await go(async () => getAll(airnodeAddress, ignoreAfterTimestamp)); if (!goReadDb.success) { - return generateErrorResponse(500, 'Unable to get signed data from database', goReadDb.error.message); + return generateErrorResponse(500, 'Unable to get signed data from database', { detail: goReadDb.error.message }); } const data = goReadDb.data.reduce((acc, signedData) => { @@ -156,7 +160,7 @@ export const getData = async (airnodeAddress: string, delaySeconds: number): Pro export const listAirnodeAddresses = async (): Promise => { const goAirnodeAddresses = await go(async () => getAllAirnodeAddresses()); if (!goAirnodeAddresses.success) { - return generateErrorResponse(500, 'Unable to scan database', goAirnodeAddresses.error.message); + return generateErrorResponse(500, 'Unable to scan database', { detail: goAirnodeAddresses.error.message }); } const airnodeAddresses = goAirnodeAddresses.data; diff --git a/packages/api/src/utils.ts b/packages/api/src/utils.ts index 6d21a6d1..e19bcf88 100644 --- a/packages/api/src/utils.ts +++ b/packages/api/src/utils.ts @@ -15,8 +15,11 @@ export const isIgnored = (signedData: SignedData, ignoreAfterTimestamp: number) export const generateErrorResponse = ( statusCode: number, message: string, - detail?: string, - extra?: unknown + context?: Record ): ApiResponse => { - return { statusCode, headers: createResponseHeaders(), body: JSON.stringify({ message, detail, extra }) }; + return { + statusCode, + headers: createResponseHeaders(), + body: JSON.stringify(context ? { message, context } : { message }), + }; };