From 5f2b6b43cd1b7bcea7a4533ad7a52b34b881c760 Mon Sep 17 00:00:00 2001 From: Thomas van Dam Date: Wed, 27 Nov 2024 14:38:16 +0100 Subject: [PATCH] feat(data-proxy)!: add handshake method The OPTIONS http method is no longer allowed, as we need this to be able to reliably retrieve the data proxy info from the overlay network. Closes: #34 --- workspace/data-proxy-sdk/src/data-proxy.ts | 2 +- .../data-proxy/src/config-parser.test.ts | 35 +++++++++++- workspace/data-proxy/src/config-parser.ts | 33 ++++++++++-- workspace/data-proxy/src/constants.ts | 4 +- workspace/data-proxy/src/proxy-server.test.ts | 54 ++++++++++++++++++- workspace/data-proxy/src/proxy-server.ts | 14 +++-- 6 files changed, 130 insertions(+), 12 deletions(-) diff --git a/workspace/data-proxy-sdk/src/data-proxy.ts b/workspace/data-proxy-sdk/src/data-proxy.ts index dc21dfd..328bd41 100644 --- a/workspace/data-proxy-sdk/src/data-proxy.ts +++ b/workspace/data-proxy-sdk/src/data-proxy.ts @@ -33,7 +33,7 @@ export interface SignedData { } export class DataProxy { - private version = "0.1.0"; + public version = "0.1.0"; public publicKey: Buffer; private privateKey: Buffer; public options: DataProxyOptions; diff --git a/workspace/data-proxy/src/config-parser.test.ts b/workspace/data-proxy/src/config-parser.test.ts index 0f9875a..eefa8be 100644 --- a/workspace/data-proxy/src/config-parser.test.ts +++ b/workspace/data-proxy/src/config-parser.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { assertIsOkResult } from "@seda-protocol/utils/testing"; +import { + assertIsErrorResult, + assertIsOkResult, +} from "@seda-protocol/utils/testing"; import { parseConfig } from "./config-parser"; describe("parseConfig", () => { @@ -120,4 +123,34 @@ describe("parseConfig", () => { expect(result).toBeOkResult(); }); + + it("should fail if the status endpoint uses a the same route group as the proxy", () => { + const result = parseConfig({ + statusEndpoints: { + root: "proxy", + }, + routes: [], + }); + + assertIsErrorResult(result); + expect(result.error).toContain("can not be the same"); + }); + + it.each(["OPTIONS", ["OPTIONS", "GET"]])( + "should error when the OPTIONS method is used for a route", + (method) => { + const resultSingle = parseConfig({ + routes: [ + { + method, + path: "/:coinA/*", + upstreamUrl: "aaaaaa.com/{*}", + }, + ], + }); + + assertIsErrorResult(resultSingle); + expect(resultSingle.error).toContain("OPTIONS method is reserved"); + }, + ); }); diff --git a/workspace/data-proxy/src/config-parser.ts b/workspace/data-proxy/src/config-parser.ts index 2f630f1..6f9f6db 100644 --- a/workspace/data-proxy/src/config-parser.ts +++ b/workspace/data-proxy/src/config-parser.ts @@ -1,11 +1,16 @@ +import { tryParseSync } from "@seda-protocol/utils"; import { maybe } from "@seda-protocol/utils/valibot"; import type { HTTPMethod } from "elysia"; import { Result } from "true-myth"; import * as v from "valibot"; -import { DEFAULT_HTTP_METHODS, DEFAULT_PROXY_ROUTE_GROUP } from "./constants"; +import { DEFAULT_HTTP_METHODS, PROXY_ROUTE_GROUP } from "./constants"; import { replaceParams } from "./utils/replace-params"; -const HttpMethodSchema = v.union([v.string(), v.array(v.string())]); +const NotOptionsMethod = v.pipe( + v.string(), + v.notValue("OPTIONS", "OPTIONS method is reserved"), +); +const HttpMethodSchema = v.union([NotOptionsMethod, v.array(NotOptionsMethod)]); const RouteSchema = v.object({ baseURL: maybe(v.string()), @@ -23,7 +28,7 @@ const RouteSchema = v.object({ }); const ConfigSchema = v.object({ - routeGroup: v.optional(v.string(), DEFAULT_PROXY_ROUTE_GROUP), + routeGroup: v.optional(v.string(), PROXY_ROUTE_GROUP), routes: v.array(RouteSchema), baseURL: maybe(v.string()), statusEndpoints: v.optional( @@ -58,7 +63,27 @@ const pathRegex = new RegExp(/{(:[^}]+)}/g, "g"); const envVariablesRegex = new RegExp(/{(\$[^}]+)}/g, "g"); export function parseConfig(input: unknown): Result { - const config = v.parse(ConfigSchema, input); + const configResult = tryParseSync(ConfigSchema, input); + if (configResult.isErr) { + return Result.err( + configResult.error + .map((err) => { + const key = err.path?.reduce((path, segment) => { + return path.concat(".", segment.key as string); + }, ""); + return `${key}: ${err.message}`; + }) + .join("\n"), + ); + } + + const config = configResult.value; + + if (config.statusEndpoints.root === config.routeGroup) { + return Result.err( + `"statusEndpoints.root" can not be the same as "routeGroup" (value: ${PROXY_ROUTE_GROUP})`, + ); + } if (config.statusEndpoints.apiKey) { const statusApiSecretEnvMatches = diff --git a/workspace/data-proxy/src/constants.ts b/workspace/data-proxy/src/constants.ts index 0459ea7..29704cf 100644 --- a/workspace/data-proxy/src/constants.ts +++ b/workspace/data-proxy/src/constants.ts @@ -19,7 +19,8 @@ export const DEFAULT_PRIVATE_KEY_JSON_FILE_NAME = "./data-proxy-private-key.json"; // Where all the proxy routes go to (For example /proxy/CONFIGURED_ROUTE_HERE) -export const DEFAULT_PROXY_ROUTE_GROUP = "proxy"; +export const PROXY_ROUTE_GROUP = "proxy"; +export const INFO_ROUTE_GROUP = "info"; // Default http methods set when no method is provided in the config export const DEFAULT_HTTP_METHODS: HTTPMethod[] = [ "GET", @@ -27,6 +28,5 @@ export const DEFAULT_HTTP_METHODS: HTTPMethod[] = [ "POST", "PUT", "DELETE", - "OPTIONS", "HEAD", ]; diff --git a/workspace/data-proxy/src/proxy-server.test.ts b/workspace/data-proxy/src/proxy-server.test.ts index 0a3182f..5ccbf67 100644 --- a/workspace/data-proxy/src/proxy-server.test.ts +++ b/workspace/data-proxy/src/proxy-server.test.ts @@ -7,7 +7,11 @@ import { it, } from "bun:test"; import { Secp256k1, Secp256k1Signature, keccak256 } from "@cosmjs/crypto"; -import { DataProxy, Environment } from "@seda-protocol/data-proxy-sdk"; +import { + constants, + DataProxy, + Environment, +} from "@seda-protocol/data-proxy-sdk"; import { Maybe } from "true-myth"; import { startProxyServer } from "./proxy-server"; import { @@ -252,6 +256,54 @@ describe("proxy server", () => { }); }); + describe("OPTIONS methods", () => { + it("should return the public key and version of the data proxy", async () => { + const { upstreamUrl, proxyUrl, path, port } = registerHandler( + "get", + "/test", + async () => { + return HttpResponse.json({ data: "info" }); + }, + ); + + const proxy = startProxyServer( + { + routeGroup: "", + statusEndpoints: { + root: "status", + }, + baseURL: Maybe.nothing(), + routes: [ + { + baseURL: Maybe.nothing(), + method: "GET", + path, + upstreamUrl, + forwardResponseHeaders: new Set([]), + headers: {}, + jsonPath: "$.data", + }, + ], + }, + dataProxy, + { + disableProof: true, + port, + }, + ); + + const response = await fetch(proxyUrl, { method: "OPTIONS" }); + const version = response.headers.get( + constants.SIGNATURE_VERSION_HEADER_KEY, + ); + const publicKey = response.headers.get(constants.PUBLIC_KEY_HEADER_KEY); + + expect(version).toBe("0.1.0"); + expect(publicKey).toBe( + "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + ); + }); + }); describe("status endpoints", () => { it("should return the status of the proxy for /health", async () => { const { upstreamUrl, proxyUrl, path, port } = registerHandler( diff --git a/workspace/data-proxy/src/proxy-server.ts b/workspace/data-proxy/src/proxy-server.ts index 08c8564..9515345 100644 --- a/workspace/data-proxy/src/proxy-server.ts +++ b/workspace/data-proxy/src/proxy-server.ts @@ -4,7 +4,7 @@ import { tryAsync } from "@seda-protocol/utils"; import { Elysia } from "elysia"; import { Maybe } from "true-myth"; import { type Config, getHttpMethods } from "./config-parser"; -import { DEFAULT_PROXY_ROUTE_GROUP, JSON_PATH_HEADER_KEY } from "./constants"; +import { JSON_PATH_HEADER_KEY, PROXY_ROUTE_GROUP } from "./constants"; import logger from "./logger"; import { StatusContext, statusPlugin } from "./status-plugin"; import { @@ -68,7 +68,7 @@ export function startProxyServer( const statusContext = new StatusContext(dataProxy.publicKey.toString("hex")); server.use(statusPlugin(statusContext, config.statusEndpoints)); - const proxyGroup = config.routeGroup ?? DEFAULT_PROXY_ROUTE_GROUP; + const proxyGroup = config.routeGroup ?? PROXY_ROUTE_GROUP; server.group(proxyGroup, (app) => { // Only update the status context in routes that are part of the proxy group @@ -80,9 +80,17 @@ export function startProxyServer( }); for (const route of config.routes) { - const routeMethods = getHttpMethods(route.method); + app.route("OPTIONS", route.path, () => { + const headers = new Headers({ + [constants.PUBLIC_KEY_HEADER_KEY]: + dataProxy.publicKey.toString("hex"), + [constants.SIGNATURE_VERSION_HEADER_KEY]: dataProxy.version, + }); + return new Response(null, { headers }); + }); // A route can have multiple methods attach to it + const routeMethods = getHttpMethods(route.method); for (const routeMethod of routeMethods) { app.route( routeMethod,