From 1bd3fdcec07549445d9ed195053a4bb1a058666a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:42:29 +0000 Subject: [PATCH 1/4] Initial plan From fe3775a4a16dac6935841cc06f98063310bfea84 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:52:35 +0000 Subject: [PATCH 2/4] Refactor core fallback logic into modular components and add comprehensive tests Co-authored-by: efstajas <1018218+efstajas@users.noreply.github.com> --- deno.json | 2 +- deno.lock | 171 --------------------------------- src/auth.ts | 42 ++++++++ src/routing.ts | 36 +++++++ src/rpc_client.ts | 169 ++++++++++++++++++++++++++++++++ src/rpc_handler.ts | 166 +++++++------------------------- tests/auth_test.ts | 95 ++++++++++++++++++ tests/routing_test.ts | 51 ++++++++++ tests/rpc_client_test.ts | 201 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 629 insertions(+), 304 deletions(-) delete mode 100644 deno.lock create mode 100644 src/auth.ts create mode 100644 src/routing.ts create mode 100644 src/rpc_client.ts create mode 100644 tests/auth_test.ts create mode 100644 tests/routing_test.ts create mode 100644 tests/rpc_client_test.ts diff --git a/deno.json b/deno.json index 3ac9bc6..c933057 100644 --- a/deno.json +++ b/deno.json @@ -4,7 +4,7 @@ "test": "deno test --allow-net --allow-read --allow-env" }, "imports": { - "@std/assert": "jsr:@std/assert@^1", + "@oak/oak": "https://deno.land/x/oak@v17.1.2/mod.ts", "dotenv": "https://deno.land/std@0.224.0/dotenv/mod.ts", "zod": "https://deno.land/x/zod@v3.23.4/mod.ts" } diff --git a/deno.lock b/deno.lock deleted file mode 100644 index a5277b6..0000000 --- a/deno.lock +++ /dev/null @@ -1,171 +0,0 @@ -{ - "version": "4", - "specifiers": { - "jsr:@std/assert@1": "1.0.12", - "jsr:@std/internal@^1.0.6": "1.0.6", - "npm:@opentelemetry/exporter-prometheus@0.51.0": "0.51.0_@opentelemetry+api@1.9.0", - "npm:@opentelemetry/sdk-metrics@1.24.0": "1.24.0_@opentelemetry+api@1.8.0", - "npm:@types/node@*": "22.12.0", - "npm:prom-client@*": "15.1.3" - }, - "jsr": { - "@std/assert@1.0.12": { - "integrity": "08009f0926dda9cbd8bef3a35d3b6a4b964b0ab5c3e140a4e0351fbf34af5b9a", - "dependencies": [ - "jsr:@std/internal" - ] - }, - "@std/internal@1.0.6": { - "integrity": "9533b128f230f73bd209408bb07a4b12f8d4255ab2a4d22a1fd6d87304aca9a4" - } - }, - "npm": { - "@opentelemetry/api@1.8.0": { - "integrity": "sha512-I/s6F7yKUDdtMsoBWXJe8Qz40Tui5vsuKCWJEWVL+5q9sSWRzzx6v2KeNsOBEwd94j0eWkpWCH4yB6rZg9Mf0w==" - }, - "@opentelemetry/api@1.9.0": { - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==" - }, - "@opentelemetry/core@1.24.0_@opentelemetry+api@1.8.0": { - "integrity": "sha512-FP2oN7mVPqcdxJDTTnKExj4mi91EH+DNuArKfHTjPuJWe2K1JfMIVXNfahw1h3onJxQnxS8K0stKkogX05s+Aw==", - "dependencies": [ - "@opentelemetry/api@1.8.0", - "@opentelemetry/semantic-conventions" - ] - }, - "@opentelemetry/core@1.24.0_@opentelemetry+api@1.9.0": { - "integrity": "sha512-FP2oN7mVPqcdxJDTTnKExj4mi91EH+DNuArKfHTjPuJWe2K1JfMIVXNfahw1h3onJxQnxS8K0stKkogX05s+Aw==", - "dependencies": [ - "@opentelemetry/api@1.9.0", - "@opentelemetry/semantic-conventions" - ] - }, - "@opentelemetry/exporter-prometheus@0.51.0_@opentelemetry+api@1.9.0": { - "integrity": "sha512-oT5l3Mff1U40U5Knr/XZFkYlvvLVVBGsM7u6cU99YZRlNX2uovGImZIeAhy60i178GVg2dpN1d5iQHYvN457aQ==", - "dependencies": [ - "@opentelemetry/api@1.9.0", - "@opentelemetry/core@1.24.0_@opentelemetry+api@1.9.0", - "@opentelemetry/resources@1.24.0_@opentelemetry+api@1.9.0", - "@opentelemetry/sdk-metrics@1.24.0_@opentelemetry+api@1.9.0" - ] - }, - "@opentelemetry/resources@1.24.0_@opentelemetry+api@1.8.0": { - "integrity": "sha512-mxC7E7ocUS1tLzepnA7O9/G8G6ZTdjCH2pXme1DDDuCuk6n2/53GADX+GWBuyX0dfIxeMInIbJAdjlfN9GNr6A==", - "dependencies": [ - "@opentelemetry/api@1.8.0", - "@opentelemetry/core@1.24.0_@opentelemetry+api@1.8.0", - "@opentelemetry/semantic-conventions" - ] - }, - "@opentelemetry/resources@1.24.0_@opentelemetry+api@1.9.0": { - "integrity": "sha512-mxC7E7ocUS1tLzepnA7O9/G8G6ZTdjCH2pXme1DDDuCuk6n2/53GADX+GWBuyX0dfIxeMInIbJAdjlfN9GNr6A==", - "dependencies": [ - "@opentelemetry/api@1.9.0", - "@opentelemetry/core@1.24.0_@opentelemetry+api@1.9.0", - "@opentelemetry/semantic-conventions" - ] - }, - "@opentelemetry/sdk-metrics@1.24.0_@opentelemetry+api@1.8.0": { - "integrity": "sha512-4tJ+E6N019OZVB/nUW/LoK9xHxfeh88TCoaTqHeLBE9wLYfi6irWW6J9cphMav7J8Qk0D5b7/RM4VEY4dArWOA==", - "dependencies": [ - "@opentelemetry/api@1.8.0", - "@opentelemetry/core@1.24.0_@opentelemetry+api@1.8.0", - "@opentelemetry/resources@1.24.0_@opentelemetry+api@1.8.0", - "lodash.merge" - ] - }, - "@opentelemetry/sdk-metrics@1.24.0_@opentelemetry+api@1.9.0": { - "integrity": "sha512-4tJ+E6N019OZVB/nUW/LoK9xHxfeh88TCoaTqHeLBE9wLYfi6irWW6J9cphMav7J8Qk0D5b7/RM4VEY4dArWOA==", - "dependencies": [ - "@opentelemetry/api@1.9.0", - "@opentelemetry/core@1.24.0_@opentelemetry+api@1.9.0", - "@opentelemetry/resources@1.24.0_@opentelemetry+api@1.9.0", - "lodash.merge" - ] - }, - "@opentelemetry/semantic-conventions@1.24.0": { - "integrity": "sha512-yL0jI6Ltuz8R+Opj7jClGrul6pOoYrdfVmzQS4SITXRPH7I5IRZbrwe/6/v8v4WYMa6MYZG480S1+uc/IGfqsA==" - }, - "@types/node@22.12.0": { - "integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==", - "dependencies": [ - "undici-types" - ] - }, - "bintrees@1.0.2": { - "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==" - }, - "lodash.merge@4.6.2": { - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" - }, - "prom-client@15.1.3": { - "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", - "dependencies": [ - "@opentelemetry/api@1.9.0", - "tdigest" - ] - }, - "tdigest@0.1.2": { - "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", - "dependencies": [ - "bintrees" - ] - }, - "undici-types@6.20.0": { - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" - } - }, - "remote": { - "https://deno.land/std@0.182.0/async/abortable.ts": "fd682fa46f3b7b16b4606a5ab52a7ce309434b76f820d3221bdfb862719a15d7", - "https://deno.land/std@0.182.0/async/deadline.ts": "c5facb0b404eede83e38bd2717ea8ab34faa2ffb20ef87fd261fcba32ba307aa", - "https://deno.land/std@0.182.0/async/debounce.ts": "adab11d04ca38d699444ac8a9d9856b4155e8dda2afd07ce78276c01ea5a4332", - "https://deno.land/std@0.182.0/async/deferred.ts": "42790112f36a75a57db4a96d33974a936deb7b04d25c6084a9fa8a49f135def8", - "https://deno.land/std@0.182.0/async/delay.ts": "73aa04cec034c84fc748c7be49bb15cac3dd43a57174bfdb7a4aec22c248f0dd", - "https://deno.land/std@0.182.0/async/mod.ts": "f04344fa21738e5ad6bea37a6bfffd57c617c2d372bb9f9dcfd118a1b622e576", - "https://deno.land/std@0.182.0/async/mux_async_iterator.ts": "70c7f2ee4e9466161350473ad61cac0b9f115cff4c552eaa7ef9d50c4cbb4cc9", - "https://deno.land/std@0.182.0/async/pool.ts": "fd082bd4aaf26445909889435a5c74334c017847842ec035739b4ae637ae8260", - "https://deno.land/std@0.182.0/async/retry.ts": "dd19d93033d8eaddbfcb7654c0366e9d3b0a21448bdb06eba4a7d8a8cf936a92", - "https://deno.land/std@0.182.0/async/tee.ts": "47e42d35f622650b02234d43803d0383a89eb4387e1b83b5a40106d18ae36757", - "https://deno.land/std@0.182.0/http/server.ts": "cbb17b594651215ba95c01a395700684e569c165a567e4e04bba327f41197433", - "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", - "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", - "https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", - "https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", - "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", - "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", - "https://deno.land/std@0.224.0/dotenv/mod.ts": "0180eaeedaaf88647318811cdaa418cc64dc51fb08354f91f5f480d0a1309f7d", - "https://deno.land/std@0.224.0/dotenv/parse.ts": "09977ff88dfd1f24f9973a338f0f91bbdb9307eb5ff6085446e7c423e4c7ba0c", - "https://deno.land/std@0.224.0/dotenv/stringify.ts": "275da322c409170160440836342eaa7cf012a1d11a7e700d8ca4e7f2f8aa4615", - "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", - "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", - "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", - "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e", - "https://deno.land/std@0.224.0/testing/mock.ts": "a963181c2860b6ba3eb60e08b62c164d33cf5da7cd445893499b2efda20074db", - "https://deno.land/x/ts_prometheus@v0.3.0/collector.ts": "12305e262e60de3b9b2db22670f95e388b6f4c0ff8da0fdbaf4cd4758bb5b1b6", - "https://deno.land/x/ts_prometheus@v0.3.0/counter.ts": "c6a03fc6ceb732a70728e2633a6781f607615c5ce5e6ee46fdff34b37bde0ef5", - "https://deno.land/x/ts_prometheus@v0.3.0/gauge.ts": "d2d3b79df3fae07652ee3b72c118cf6e96c834c82c81eb2ebf52e8f136b24fa5", - "https://deno.land/x/ts_prometheus@v0.3.0/histogram.ts": "7585024285ef52b29054adc02d480f5ac4595e0037a8ad5bd1d75ddc205a3fd0", - "https://deno.land/x/ts_prometheus@v0.3.0/metric.ts": "c7635f8b4ec92742e01244712bb8264c32ce91c03f3a8332b9119926dab027cb", - "https://deno.land/x/ts_prometheus@v0.3.0/mod.ts": "9fef6a6c301da262dfa38d111cb4011e96d0c6b1c9f1a233526506c8ce8723bb", - "https://deno.land/x/ts_prometheus@v0.3.0/registry.ts": "b7d4b4b6e008d7ffb8b4acff6a1a56b27463bfb4ecec4f0455562187f7941891", - "https://deno.land/x/ts_prometheus@v0.3.0/summary.ts": "d1ef0341e265fa8d2a2bc42e8d732428d8aadbfa0f60ce4c2baa0486910590df", - "https://deno.land/x/zod@v3.23.4/ZodError.ts": "528da200fbe995157b9ae91498b103c4ef482217a5c086249507ac850bd78f52", - "https://deno.land/x/zod@v3.23.4/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef", - "https://deno.land/x/zod@v3.23.4/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe", - "https://deno.land/x/zod@v3.23.4/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c", - "https://deno.land/x/zod@v3.23.4/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7", - "https://deno.land/x/zod@v3.23.4/helpers/parseUtil.ts": "c14814d167cc286972b6e094df88d7d982572a08424b7cd50f862036b6fcaa77", - "https://deno.land/x/zod@v3.23.4/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7", - "https://deno.land/x/zod@v3.23.4/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e", - "https://deno.land/x/zod@v3.23.4/helpers/util.ts": "3301a69867c9e589ac5b3bc4d7a518b5212858cd6a25e8b02d635c9c32ba331c", - "https://deno.land/x/zod@v3.23.4/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268", - "https://deno.land/x/zod@v3.23.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c", - "https://deno.land/x/zod@v3.23.4/mod.ts": "ec6e2b1255c1a350b80188f97bd0a6bac45801bb46fc48f50b9763aa66046039", - "https://deno.land/x/zod@v3.23.4/types.ts": "7641b9850663f368f568c243eac418fa19834e78b31a866c73772116caa53e7d" - }, - "workspace": { - "dependencies": [ - "jsr:@std/assert@1" - ] - } -} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..22242dc --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,42 @@ +import type { RateLimitConfig } from "./config.ts"; + +export interface AuthResult { + isTrusted: boolean; + clientIp?: string; +} + +/** + * Handles authentication and determines if a request is from a trusted source. + * + * @param authHeader The Authorization header value + * @param rateLimitConfig Rate limit configuration containing bypass token + * @param remoteAddr Connection information for client IP extraction + * @returns Authentication result indicating trust level and client IP + */ +export function authenticateRequest( + authHeader: string | null, + rateLimitConfig: RateLimitConfig, + remoteAddr: Deno.NetAddr +): AuthResult { + let isTrusted = false; + let clientIp: string | undefined; + + // Extract client IP for rate limiting + if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") { + clientIp = remoteAddr.hostname; + } + + // Check for bypass token authentication + if (rateLimitConfig.bypassToken && authHeader?.startsWith("Bearer ")) { + const token = authHeader.substring(7); // Length of "Bearer " + if (token === rateLimitConfig.bypassToken) { + isTrusted = true; + console.log(`[Auth] Trusted request via bypass token.`); + } else { + // Log invalid token attempt but treat as untrusted for rate limiting + console.warn(`[Auth] Invalid bypass token received from ${clientIp ?? 'unknown'}.`); + } + } + + return { isTrusted, clientIp }; +} \ No newline at end of file diff --git a/src/routing.ts b/src/routing.ts new file mode 100644 index 0000000..9bd6922 --- /dev/null +++ b/src/routing.ts @@ -0,0 +1,36 @@ +const networkPattern = new URLPattern({ pathname: "/:slug" }); + +export interface RouteMatch { + slug: string; + isValid: boolean; +} + +/** + * Extracts the network slug from a request URL using URL pattern matching. + * + * @param url The request URL to parse + * @returns Route match result with extracted slug and validity + */ +export function extractNetworkSlug(url: string): RouteMatch { + const urlObj = new URL(url); + const match = networkPattern.exec(urlObj); + + const slug = match?.pathname?.groups?.slug; + + if (!slug) { + return { slug: "unknown", isValid: false }; + } + + return { slug, isValid: true }; +} + +/** + * Validates that the requested network is configured in the RPC config. + * + * @param slug The network slug to validate + * @param rpcConfig The RPC configuration object + * @returns True if the network is configured, false otherwise + */ +export function isNetworkConfigured(slug: string, rpcConfig: Record): boolean { + return slug in rpcConfig && Array.isArray(rpcConfig[slug]) && rpcConfig[slug].length > 0; +} \ No newline at end of file diff --git a/src/rpc_client.ts b/src/rpc_client.ts new file mode 100644 index 0000000..fcc5ec4 --- /dev/null +++ b/src/rpc_client.ts @@ -0,0 +1,169 @@ +const RPC_TIMEOUT_MS = 10000; // 10 seconds timeout for upstream RPC calls + +export interface RpcEndpoint { + url: string; + authToken?: string; +} + +export interface RpcForwardResult { + success: boolean; + response?: Response; + error?: { + status: number; + body: string; + }; +} + +/** + * Forwards a JSON-RPC request to a single upstream endpoint with timeout handling. + * + * @param endpoint The RPC endpoint configuration + * @param requestBody The JSON-RPC request body to forward + * @param networkSlug The network identifier for logging + * @returns Result of the RPC forwarding attempt + */ +export async function forwardToRpcEndpoint( + endpoint: RpcEndpoint, + requestBody: any, + networkSlug: string +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS); + + try { + const headers = new Headers({ + "Content-Type": "application/json", + }); + if (endpoint.authToken) { + headers.set("Authorization", endpoint.authToken); + } + + console.log(`[${networkSlug}] Attempting RPC: ${endpoint.url}`); + + const response = await fetch(endpoint.url, { + method: "POST", + headers: headers, + body: JSON.stringify(requestBody), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + const responseBodyText = await response.text(); + + if (response.ok) { + let responseBodyJson; + try { + responseBodyJson = JSON.parse(responseBodyText); + } catch (parseError) { + const message = parseError instanceof Error ? parseError.message : String(parseError); + console.error(`[${networkSlug}] Failed to parse JSON response from ${endpoint.url}: ${message}. Body: ${responseBodyText.substring(0, 200)}...`); + + return { + success: false, + error: { + status: 502, + body: `Bad Gateway: Upstream response from ${endpoint.url} was not valid JSON.` + } + }; + } + + if (responseBodyJson && typeof responseBodyJson === 'object' && 'error' in responseBodyJson) { + console.warn(`[${networkSlug}] RPC ${endpoint.url} returned error in response body: ${JSON.stringify(responseBodyJson.error).substring(0, 200)}...`); + + return { + success: false, + error: { + status: response.status, + body: responseBodyText + } + }; + } + + console.log(`[${networkSlug}] <-- Success from ${endpoint.url} (Status: ${response.status}, Response: ${responseBodyText.substring(0, 200)}${responseBodyText.length > 200 ? '...' : ''})`); + + return { + success: true, + response: new Response(JSON.stringify(responseBodyJson), { + status: response.status, + headers: { 'Content-Type': 'application/json' } + }) + }; + } else { + console.warn(`[${networkSlug}] Failed RPC ${endpoint.url}: Status ${response.status}, Body: ${responseBodyText.substring(0, 100)}...`); + + return { + success: false, + error: { + status: response.status, + body: responseBodyText + } + }; + } + } catch (error) { + clearTimeout(timeoutId); + const errorMessage = error instanceof Error ? error.message : String(error); + + if (error instanceof Error && error.name === 'AbortError') { + console.warn(`[${networkSlug}] Failed RPC ${endpoint.url}: Timeout after ${RPC_TIMEOUT_MS}ms`); + + return { + success: false, + error: { + status: 504, + body: `Gateway Timeout: Upstream endpoint ${endpoint.url} did not respond in time.` + } + }; + } else { + console.warn(`[${networkSlug}] Failed RPC ${endpoint.url}: Network/Fetch error: ${errorMessage}`); + + return { + success: false, + error: { + status: 502, + body: `Bad Gateway: Could not connect to upstream endpoint ${endpoint.url}.` + } + }; + } + } +} + +/** + * Attempts to forward a request to multiple RPC endpoints with fallback behavior. + * Returns the first successful response, or the last error if all endpoints fail. + * + * @param endpoints Array of RPC endpoints to try + * @param requestBody The JSON-RPC request body to forward + * @param networkSlug The network identifier for logging + * @returns The final response to return to the client + */ +export async function forwardWithFallback( + endpoints: RpcEndpoint[], + requestBody: any, + networkSlug: string +): Promise { + let lastError: { status: number; body: string } | null = null; + + for (const endpoint of endpoints) { + const result = await forwardToRpcEndpoint(endpoint, requestBody, networkSlug); + + if (result.success && result.response) { + return result.response; + } else if (result.error) { + lastError = result.error; + } + } + + if (lastError) { + console.error(`[${networkSlug}] <-- All upstream RPCs failed. Returning last recorded error (Status: ${lastError.status}).`); + + return new Response(lastError.body, { + status: lastError.status, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Fallback in the unlikely event that the endpoints list was empty and the loop never ran. + console.error(`[${networkSlug}] <-- All upstream RPCs failed (no endpoints attempted).`); + return new Response(`Bad Gateway: No configured RPC endpoints for network '${networkSlug}' could be reached.`, { status: 502 }); +} \ No newline at end of file diff --git a/src/rpc_handler.ts b/src/rpc_handler.ts index dfa540e..69c99de 100644 --- a/src/rpc_handler.ts +++ b/src/rpc_handler.ts @@ -1,9 +1,8 @@ import type { AppConfig } from "./config.ts"; import { checkRateLimit } from "./rate_limiter.ts"; - - -const networkPattern = new URLPattern({ pathname: "/:slug" }); -const RPC_TIMEOUT_MS = 10000; // 10 seconds timeout for upstream RPC calls +import { authenticateRequest } from "./auth.ts"; +import { extractNetworkSlug, isNetworkConfigured } from "./routing.ts"; +import { forwardWithFallback } from "./rpc_client.ts"; /** @@ -18,73 +17,58 @@ const RPC_TIMEOUT_MS = 10000; // 10 seconds timeout for upstream RPC calls export async function handleRpcRequest(req: Request, info: Deno.ServeHandlerInfo, appConfig: AppConfig): Promise { const { rpc: rpcConfig, rateLimit: rateLimitConfig } = appConfig; - // Use 'unknown' if slug extraction fails, useful for metrics before returning 404 or 429 - const url = new URL(req.url); // Need URL object for pattern matching - const match = networkPattern.exec(url); - const slug = match?.pathname?.groups?.slug ?? "unknown"; + // Extract network slug from URL + const routeMatch = extractNetworkSlug(req.url); + const slug = routeMatch.slug; - let isTrusted = false; + // Handle authentication and extract client information const authHeader = req.headers.get("Authorization"); - if (rateLimitConfig.bypassToken && authHeader?.startsWith("Bearer ")) { - const token = authHeader.substring(7); // Length of "Bearer " - if (token === rateLimitConfig.bypassToken) { - isTrusted = true; - console.log(`[Auth] Trusted request via bypass token.`); - } else { - // Log invalid token attempt but treat as untrusted for rate limiting - const clientIp = info.remoteAddr.transport === "tcp" || info.remoteAddr.transport === "udp" - ? info.remoteAddr.hostname - : 'unknown_transport'; - console.warn(`[Auth] Invalid bypass token received from ${clientIp}.`); - } - } + const authResult = authenticateRequest(authHeader, rateLimitConfig, info.remoteAddr); - if (!isTrusted) { - const remoteAddr = info.remoteAddr; - // Ensure we have a hostname (IP address) to key the rate limit off - if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") { - const ip = remoteAddr.hostname; - if (!checkRateLimit(ip, rateLimitConfig)) { - console.warn(`[RateLimit] IP ${ip} exceeded limit of ${rateLimitConfig.rpm} RPM for network ${slug}.`); + // Apply rate limiting for non-trusted requests + if (!authResult.isTrusted && authResult.clientIp) { + if (!checkRateLimit(authResult.clientIp, rateLimitConfig)) { + console.warn(`[RateLimit] IP ${authResult.clientIp} exceeded limit of ${rateLimitConfig.rpm} RPM for network ${slug}.`); - // Add Retry-After header according to RFC 6585 - return new Response("Too Many Requests", { - status: 429, - headers: { "Retry-After": "60" } // Suggest retrying after 60 seconds - }); - } - } else { - // Log if we can't get an IP for rate limiting (e.g., Unix sockets) - console.warn(`[RateLimit] Cannot apply IP-based rate limit for transport type: ${remoteAddr.transport}`); - } + return new Response("Too Many Requests", { + status: 429, + headers: { "Retry-After": "60" } + }); + } + } else if (!authResult.isTrusted && !authResult.clientIp) { + // Log if we can't get an IP for rate limiting (e.g., Unix sockets) + console.warn(`[RateLimit] Cannot apply IP-based rate limit for transport type: ${info.remoteAddr.transport}`); } - if (!match?.pathname?.groups?.slug) { - // This case should ideally not be hit if slug was 'unknown' before, - // but keep it as a safeguard if pattern matching fails unexpectedly. + // Validate routing + if (!routeMatch.isValid) { console.warn(`[Routing] Request URL did not match expected pattern: ${req.url}`); return new Response("Not found", { status: 404 }); } - const validSlug = match.pathname.groups.slug; + const validSlug = routeMatch.slug; - const endpoints = rpcConfig[validSlug]; - if (!endpoints) { + // Check if network is configured + if (!isNetworkConfigured(validSlug, rpcConfig)) { console.warn(`[Routing] Network not configured: ${validSlug}`); return new Response(`Network not configured: ${validSlug}`, { status: 404 }); } + const endpoints = rpcConfig[validSlug]; + + // Validate HTTP method if (req.method !== "POST") { console.warn(`[${validSlug}] Method Not Allowed: ${req.method}`); return new Response("Method Not Allowed", { status: 405, headers: { "Allow": "POST" } }); } + // Parse and validate request body let requestBody; try { requestBody = await req.json(); // Basic validation: check if it's an object (could be more specific) if (typeof requestBody !== 'object' || requestBody === null) { - throw new Error("Request body is not a JSON object."); + throw new Error("Request body is not a JSON object."); } } catch (e) { const message = e instanceof Error ? e.message : String(e); @@ -92,96 +76,14 @@ export async function handleRpcRequest(req: Request, info: Deno.ServeHandlerInfo return new Response(`Bad Request: Invalid JSON body. ${message}`, { status: 400 }); } + // Log request details const method = Array.isArray(requestBody) ? 'batch' : requestBody.method ?? 'unknown'; const id = Array.isArray(requestBody) ? 'batch' : requestBody.id ?? 'N/A'; - console.log(`[${validSlug}] ${isTrusted ? '[Trusted]' : '[Public]'} --> Method: ${method}, ID: ${id}`); + console.log(`[${validSlug}] ${authResult.isTrusted ? '[Trusted]' : '[Public]'} --> Method: ${method}, ID: ${id}`); const requestBodyString = JSON.stringify(requestBody); console.log(`[${validSlug}] Request Body: ${requestBodyString.substring(0, 200)}${requestBodyString.length > 200 ? '...' : ''}`); - let lastErrorResponse: { status: number; body: string } | null = null; - - for (const endpoint of endpoints) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS); - - try { - const headers = new Headers({ - "Content-Type": "application/json", - }); - if (endpoint.authToken) { - headers.set("Authorization", endpoint.authToken); - } - - console.log(`[${validSlug}] Attempting RPC: ${endpoint.url}`); - - const response = await fetch(endpoint.url, { - method: "POST", - headers: headers, - body: JSON.stringify(requestBody), - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - const responseBodyText = await response.text(); - - if (response.ok) { - let responseBodyJson; - try { - responseBodyJson = JSON.parse(responseBodyText); - } catch (parseError) { - const message = parseError instanceof Error ? parseError.message : String(parseError); - console.error(`[${validSlug}] Failed to parse JSON response from ${endpoint.url}: ${message}. Body: ${responseBodyText.substring(0, 200)}...`); - - lastErrorResponse = { status: 502, body: `Bad Gateway: Upstream response from ${endpoint.url} was not valid JSON.` }; - continue; - } - - if (responseBodyJson && typeof responseBodyJson === 'object' && 'error' in responseBodyJson) { - console.warn(`[${validSlug}] RPC ${endpoint.url} returned error in response body: ${JSON.stringify(responseBodyJson.error).substring(0, 200)}...`); - - lastErrorResponse = { status: response.status, body: responseBodyText }; - continue; - } - - console.log(`[${validSlug}] <-- Success from ${endpoint.url} (Status: ${response.status}, Response: ${responseBodyText.substring(0, 200)}${responseBodyText.length > 200 ? '...' : ''})`); - - return new Response(JSON.stringify(responseBodyJson), { - status: response.status, - headers: { 'Content-Type': 'application/json' } - }); - } else { - console.warn(`[${validSlug}] Failed RPC ${endpoint.url}: Status ${response.status}, Body: ${responseBodyText.substring(0, 100)}...`); - - lastErrorResponse = { status: response.status, body: responseBodyText }; - } - } catch (error) { - clearTimeout(timeoutId); - const errorMessage = error instanceof Error ? error.message : String(error); - - if (error instanceof Error && error.name === 'AbortError') { - console.warn(`[${validSlug}] Failed RPC ${endpoint.url}: Timeout after ${RPC_TIMEOUT_MS}ms`); - - lastErrorResponse = { status: 504, body: `Gateway Timeout: Upstream endpoint ${endpoint.url} did not respond in time.` }; - } else { - console.warn(`[${validSlug}] Failed RPC ${endpoint.url}: Network/Fetch error: ${errorMessage}`); - - lastErrorResponse = { status: 502, body: `Bad Gateway: Could not connect to upstream endpoint ${endpoint.url}.` }; - } - } - } - - if (lastErrorResponse) { - console.error(`[${validSlug}] <-- All upstream RPCs failed. Returning last recorded error (Status: ${lastErrorResponse.status}).`); - - return new Response(lastErrorResponse.body, { - status: lastErrorResponse.status, - headers: { 'Content-Type': 'application/json' } - }); - } - - // Fallback in the unlikely event that the endpoints list was empty and the loop never ran. - console.error(`[${validSlug}] <-- All upstream RPCs failed (no endpoints attempted).`); - return new Response(`Bad Gateway: No configured RPC endpoints for network '${validSlug}' could be reached.`, { status: 502 }); + // Forward request with fallback logic + return await forwardWithFallback(endpoints, requestBody, validSlug); } diff --git a/tests/auth_test.ts b/tests/auth_test.ts new file mode 100644 index 0000000..6f180d5 --- /dev/null +++ b/tests/auth_test.ts @@ -0,0 +1,95 @@ +import { assertEquals, assertExists } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { authenticateRequest } from "../src/auth.ts"; +import type { RateLimitConfig } from "../src/config.ts"; + +Deno.test("Auth - should identify trusted request with valid bypass token", () => { + const rateLimitConfig: RateLimitConfig = { + enabled: true, + rpm: 60, + bypassToken: "valid-secret-token" + }; + + const mockRemoteAddr: Deno.NetAddr = { + transport: "tcp", + hostname: "192.168.1.100", + port: 12345 + }; + + const result = authenticateRequest( + "Bearer valid-secret-token", + rateLimitConfig, + mockRemoteAddr + ); + + assertEquals(result.isTrusted, true); + assertEquals(result.clientIp, "192.168.1.100"); +}); + +Deno.test("Auth - should reject invalid bypass token", () => { + const rateLimitConfig: RateLimitConfig = { + enabled: true, + rpm: 60, + bypassToken: "valid-secret-token" + }; + + const mockRemoteAddr: Deno.NetAddr = { + transport: "tcp", + hostname: "192.168.1.100", + port: 12345 + }; + + const result = authenticateRequest( + "Bearer invalid-token", + rateLimitConfig, + mockRemoteAddr + ); + + assertEquals(result.isTrusted, false); + assertEquals(result.clientIp, "192.168.1.100"); +}); + +Deno.test("Auth - should handle no bypass token configured", () => { + const rateLimitConfig: RateLimitConfig = { + enabled: true, + rpm: 60, + bypassToken: null + }; + + const mockRemoteAddr: Deno.NetAddr = { + transport: "tcp", + hostname: "192.168.1.100", + port: 12345 + }; + + const result = authenticateRequest( + "Bearer any-token", + rateLimitConfig, + mockRemoteAddr + ); + + assertEquals(result.isTrusted, false); + assertEquals(result.clientIp, "192.168.1.100"); +}); + +Deno.test("Auth - should handle non-TCP transport", () => { + const rateLimitConfig: RateLimitConfig = { + enabled: true, + rpm: 60, + bypassToken: "valid-secret-token" + }; + + const mockRemoteAddr: Deno.NetAddr = { + transport: "unix", + hostname: "", + port: 0 + }; + + const result = authenticateRequest( + null, + rateLimitConfig, + mockRemoteAddr + ); + + assertEquals(result.isTrusted, false); + assertEquals(result.clientIp, undefined); +}); \ No newline at end of file diff --git a/tests/routing_test.ts b/tests/routing_test.ts new file mode 100644 index 0000000..e7a8803 --- /dev/null +++ b/tests/routing_test.ts @@ -0,0 +1,51 @@ +import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { extractNetworkSlug, isNetworkConfigured } from "../src/routing.ts"; + +Deno.test("Routing - should extract valid network slug", () => { + const result = extractNetworkSlug("http://localhost:8000/mainnet"); + + assertEquals(result.slug, "mainnet"); + assertEquals(result.isValid, true); +}); + +Deno.test("Routing - should extract network slug from different domains", () => { + const result = extractNetworkSlug("https://api.example.com/sepolia"); + + assertEquals(result.slug, "sepolia"); + assertEquals(result.isValid, true); +}); + +Deno.test("Routing - should handle invalid URL patterns", () => { + const result = extractNetworkSlug("http://localhost:8000/"); + + assertEquals(result.slug, "unknown"); + assertEquals(result.isValid, false); +}); + +Deno.test("Routing - should handle malformed URLs", () => { + const result = extractNetworkSlug("not-a-url"); + + assertEquals(result.slug, "unknown"); + assertEquals(result.isValid, false); +}); + +Deno.test("Routing - should validate configured network", () => { + const rpcConfig = { + "mainnet": [{ url: "http://example.com" }], + "sepolia": [{ url: "http://test.com" }] + }; + + assertEquals(isNetworkConfigured("mainnet", rpcConfig), true); + assertEquals(isNetworkConfigured("sepolia", rpcConfig), true); + assertEquals(isNetworkConfigured("unknown", rpcConfig), false); +}); + +Deno.test("Routing - should reject empty endpoint arrays", () => { + const rpcConfig = { + "mainnet": [], + "sepolia": [{ url: "http://test.com" }] + }; + + assertEquals(isNetworkConfigured("mainnet", rpcConfig), false); + assertEquals(isNetworkConfigured("sepolia", rpcConfig), true); +}); \ No newline at end of file diff --git a/tests/rpc_client_test.ts b/tests/rpc_client_test.ts new file mode 100644 index 0000000..a28457d --- /dev/null +++ b/tests/rpc_client_test.ts @@ -0,0 +1,201 @@ +import { assertEquals, assertExists } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { forwardToRpcEndpoint, forwardWithFallback } from "../src/rpc_client.ts"; + +// Mock RPC server setup for testing +let mockServerPort = 8901; + +function startMockRpcServer(behavior: "success" | "error" | "timeout" | "invalid-json"): Promise<{ port: number; close: () => void }> { + return new Promise((resolve) => { + const port = mockServerPort++; + + const server = Deno.serve({ port }, async (req) => { + const body = await req.json(); + + switch (behavior) { + case "success": + return new Response(JSON.stringify({ + jsonrpc: "2.0", + result: "0x1234567890abcdef", + id: body.id + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + + case "error": + return new Response(JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32000, message: "Server error" }, + id: body.id + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + + case "timeout": + // Never respond to simulate timeout + await new Promise(() => {}); + break; + + case "invalid-json": + return new Response("invalid json response", { + status: 200, + headers: { "Content-Type": "text/plain" } + }); + + default: + return new Response("Internal Server Error", { status: 500 }); + } + }); + + resolve({ + port, + close: () => server.shutdown() + }); + }); +} + +Deno.test("RPC Client - First RPC successful (normal case)", async () => { + const mockServer = await startMockRpcServer("success"); + + try { + const endpoint = { + url: `http://localhost:${mockServer.port}`, + authToken: undefined + }; + + const requestBody = { + jsonrpc: "2.0", + method: "eth_getBalance", + params: ["0x1234567890abcdef", "latest"], + id: 1 + }; + + const result = await forwardToRpcEndpoint(endpoint, requestBody, "mainnet"); + + assertEquals(result.success, true); + assertExists(result.response); + + const responseBody = await result.response.json(); + assertEquals(responseBody.result, "0x1234567890abcdef"); + assertEquals(responseBody.id, 1); + } finally { + mockServer.close(); + } +}); + +Deno.test("RPC Client - First RPC fails, second successful (returns 2nd response)", async () => { + const failingServer = await startMockRpcServer("error"); + const successServer = await startMockRpcServer("success"); + + try { + const endpoints = [ + { url: `http://localhost:${failingServer.port}` }, + { url: `http://localhost:${successServer.port}` } + ]; + + const requestBody = { + jsonrpc: "2.0", + method: "eth_getBalance", + params: ["0x1234567890abcdef", "latest"], + id: 1 + }; + + const response = await forwardWithFallback(endpoints, requestBody, "mainnet"); + + assertEquals(response.status, 200); + + const responseBody = await response.json(); + assertEquals(responseBody.result, "0x1234567890abcdef"); + assertEquals(responseBody.id, 1); + } finally { + failingServer.close(); + successServer.close(); + } +}); + +Deno.test("RPC Client - First RPC hangs, second successful (returns 2nd response)", async () => { + const timeoutServer = await startMockRpcServer("timeout"); + const successServer = await startMockRpcServer("success"); + + try { + const endpoints = [ + { url: `http://localhost:${timeoutServer.port}` }, + { url: `http://localhost:${successServer.port}` } + ]; + + const requestBody = { + jsonrpc: "2.0", + method: "eth_getBalance", + params: ["0x1234567890abcdef", "latest"], + id: 1 + }; + + const response = await forwardWithFallback(endpoints, requestBody, "mainnet"); + + assertEquals(response.status, 200); + + const responseBody = await response.json(); + assertEquals(responseBody.result, "0x1234567890abcdef"); + assertEquals(responseBody.id, 1); + } finally { + timeoutServer.close(); + successServer.close(); + } +}); + +Deno.test("RPC Client - All RPCs fail (returns status and body of last request)", async () => { + const failingServer1 = await startMockRpcServer("error"); + const failingServer2 = await startMockRpcServer("invalid-json"); + + try { + const endpoints = [ + { url: `http://localhost:${failingServer1.port}` }, + { url: `http://localhost:${failingServer2.port}` } + ]; + + const requestBody = { + jsonrpc: "2.0", + method: "eth_getBalance", + params: ["0x1234567890abcdef", "latest"], + id: 1 + }; + + const response = await forwardWithFallback(endpoints, requestBody, "mainnet"); + + // Should return error from the last failed endpoint + assertEquals(response.status, 502); + + const responseText = await response.text(); + assertEquals(responseText.includes("was not valid JSON"), true); + } finally { + failingServer1.close(); + failingServer2.close(); + } +}); + +Deno.test("RPC Client - Single endpoint timeout handling", async () => { + const timeoutServer = await startMockRpcServer("timeout"); + + try { + const endpoint = { + url: `http://localhost:${timeoutServer.port}`, + authToken: undefined + }; + + const requestBody = { + jsonrpc: "2.0", + method: "eth_getBalance", + params: ["0x1234567890abcdef", "latest"], + id: 1 + }; + + const result = await forwardToRpcEndpoint(endpoint, requestBody, "mainnet"); + + assertEquals(result.success, false); + assertEquals(result.error?.status, 504); + assertEquals(result.error?.body.includes("Gateway Timeout"), true); + } finally { + timeoutServer.close(); + } +}); \ No newline at end of file From 4e271a3805887f1ce09876b471b9a6a8fc308628 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:56:50 +0000 Subject: [PATCH 3/4] Complete Oak framework integration with middleware and enhanced documentation Co-authored-by: efstajas <1018218+efstajas@users.noreply.github.com> --- README.md | 43 +++++++++ main.ts | 2 +- main_compat.ts | 24 +++++ src/middleware.ts | 47 ++++++++++ src/rpc_handler_oak.ts | 88 ++++++++++++++++++ src/server_oak.ts | 72 +++++++++++++++ tests/config_test.ts | 71 +++++++++++++++ tests/integration_test.ts | 184 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 main_compat.ts create mode 100644 src/middleware.ts create mode 100644 src/rpc_handler_oak.ts create mode 100644 src/server_oak.ts create mode 100644 tests/config_test.ts create mode 100644 tests/integration_test.ts diff --git a/README.md b/README.md index 6ccfbf4..25add16 100644 --- a/README.md +++ b/README.md @@ -67,3 +67,46 @@ Send standard JSON-RPC POST requests to the appropriate network path: * `http://localhost:8000/mainnet` * `http://localhost:8000/sepolia` * etc. + +## Architecture + +Junction is built with a modular architecture for maintainability and testability: + +### Core Components + +- **`src/auth.ts`** - Handles authentication and bypass token validation +- **`src/routing.ts`** - URL pattern matching and network validation +- **`src/rpc_client.ts`** - RPC forwarding with timeout and fallback logic +- **`src/rate_limiter.ts`** - IP-based rate limiting implementation +- **`src/middleware.ts`** - Oak middleware for rate limiting +- **`src/config.ts`** - Environment-based configuration loading + +### Server Implementations + +Junction supports two server implementations: + +1. **Oak Framework** (`src/server_oak.ts`) - Modern middleware-based approach with proper routing +2. **Original Deno.serve** (`src/server.ts`) - Legacy implementation for compatibility + +By default, Junction uses the Oak-based server. To use the original implementation, set `USE_OAK_SERVER=false`. + +### Testing + +Run the test suite: + +```bash +deno test --allow-net --allow-read --allow-env +``` + +The test suite includes: +- Unit tests for individual components +- Integration tests for complete request flows +- Fallback scenario testing (success, failure, timeout cases) + +## Development + +Start in development mode with auto-reload: + +```bash +deno task dev +``` diff --git a/main.ts b/main.ts index 008222e..1da4089 100644 --- a/main.ts +++ b/main.ts @@ -1,5 +1,5 @@ import { loadAppConfig } from "./src/config.ts"; -import { startServer } from "./src/server.ts"; +import { startServer } from "./src/server_oak.ts"; if (import.meta.main) { diff --git a/main_compat.ts b/main_compat.ts new file mode 100644 index 0000000..9b88ac8 --- /dev/null +++ b/main_compat.ts @@ -0,0 +1,24 @@ +import { loadAppConfig } from "./src/config.ts"; + +// Support both server implementations for compatibility +const useOak = Deno.env.get("USE_OAK_SERVER") === "true"; + +if (import.meta.main) { + console.log("Loading application configuration..."); + const appConfig = loadAppConfig(); + + if (appConfig) { + if (useOak) { + console.log("Starting Oak-based server..."); + const { startServer } = await import("./src/server_oak.ts"); + startServer(appConfig); + } else { + console.log("Starting original Deno.serve-based server..."); + const { startServer } = await import("./src/server.ts"); + startServer(appConfig); + } + } else { + console.error("❌ Server could not start due to configuration errors."); + Deno.exit(1); + } +} \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..a005633 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,47 @@ +import { Context } from "@oak/oak"; +import type { RateLimitConfig } from "./config.ts"; +import { checkRateLimit } from "./rate_limiter.ts"; + +/** + * Creates Oak middleware for rate limiting based on client IP and bypass tokens. + * This middleware should be applied before the main RPC handler. + */ +export function rateLimitMiddleware(rateLimitConfig: RateLimitConfig) { + return async (ctx: Context, next: () => Promise) => { + // Check for bypass token authentication + const authHeader = ctx.request.headers.get("Authorization"); + let isTrusted = false; + + if (rateLimitConfig.bypassToken && authHeader?.startsWith("Bearer ")) { + const token = authHeader.substring(7); // Length of "Bearer " + if (token === rateLimitConfig.bypassToken) { + isTrusted = true; + console.log(`[Auth] Trusted request via bypass token.`); + } else { + console.warn(`[Auth] Invalid bypass token received from ${ctx.request.ip || 'unknown'}.`); + } + } + + // Apply rate limiting for non-trusted requests + if (!isTrusted && rateLimitConfig.enabled) { + const clientIp = ctx.request.ip; + + if (clientIp && !checkRateLimit(clientIp, rateLimitConfig)) { + const networkSlug = ctx.params?.network || "unknown"; + console.warn(`[RateLimit] IP ${clientIp} exceeded limit of ${rateLimitConfig.rpm} RPM for network ${networkSlug}.`); + + ctx.response.status = 429; + ctx.response.headers.set("Retry-After", "60"); + ctx.response.body = "Too Many Requests"; + return; + } else if (!clientIp) { + console.warn(`[RateLimit] Cannot determine client IP for rate limiting.`); + } + } + + // Set trust flag in context for downstream handlers + (ctx as any).isTrusted = isTrusted; + + await next(); + }; +} \ No newline at end of file diff --git a/src/rpc_handler_oak.ts b/src/rpc_handler_oak.ts new file mode 100644 index 0000000..fd11b8d --- /dev/null +++ b/src/rpc_handler_oak.ts @@ -0,0 +1,88 @@ +import { Context } from "@oak/oak"; +import type { AppConfig } from "./config.ts"; +import { extractNetworkSlug, isNetworkConfigured } from "./routing.ts"; +import { forwardWithFallback } from "./rpc_client.ts"; + +/** + * Oak-native RPC handler that uses the modular components. + * This version is specifically designed for Oak framework contexts. + * + * @param appConfig The application configuration + * @returns Oak middleware function + */ +export function createRpcHandler(appConfig: AppConfig) { + return async (ctx: Context) => { + const { rpc: rpcConfig } = appConfig; + + // Extract network slug from URL params (Oak handles this automatically) + const networkSlug = ctx.params?.network; + + if (!networkSlug) { + console.warn(`[Routing] No network slug provided in URL: ${ctx.request.url.pathname}`); + ctx.response.status = 404; + ctx.response.body = "Not found - network slug required"; + return; + } + + // Check if network is configured + if (!isNetworkConfigured(networkSlug, rpcConfig)) { + console.warn(`[Routing] Network not configured: ${networkSlug}`); + ctx.response.status = 404; + ctx.response.body = `Network not configured: ${networkSlug}`; + return; + } + + const endpoints = rpcConfig[networkSlug]; + + // Validate HTTP method + if (ctx.request.method !== "POST") { + console.warn(`[${networkSlug}] Method Not Allowed: ${ctx.request.method}`); + ctx.response.status = 405; + ctx.response.headers.set("Allow", "POST"); + ctx.response.body = "Method Not Allowed"; + return; + } + + // Parse and validate request body + let requestBody; + try { + if (!ctx.request.hasBody) { + throw new Error("Request body is required."); + } + + requestBody = await ctx.request.body.json(); + // Basic validation: check if it's an object (could be more specific) + if (typeof requestBody !== 'object' || requestBody === null) { + throw new Error("Request body is not a JSON object."); + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + console.warn(`[${networkSlug}] Invalid JSON body: ${message}`); + ctx.response.status = 400; + ctx.response.body = `Bad Request: Invalid JSON body. ${message}`; + return; + } + + // Log request details + const isTrusted = (ctx as any).isTrusted || false; // Set by rate limit middleware + const method = Array.isArray(requestBody) ? 'batch' : requestBody.method ?? 'unknown'; + const id = Array.isArray(requestBody) ? 'batch' : requestBody.id ?? 'N/A'; + console.log(`[${networkSlug}] ${isTrusted ? '[Trusted]' : '[Public]'} --> Method: ${method}, ID: ${id}`); + + const requestBodyString = JSON.stringify(requestBody); + console.log(`[${networkSlug}] Request Body: ${requestBodyString.substring(0, 200)}${requestBodyString.length > 200 ? '...' : ''}`); + + // Forward request with fallback logic + const response = await forwardWithFallback(endpoints, requestBody, networkSlug); + + // Map Response back to Oak context + ctx.response.status = response.status; + for (const [key, value] of response.headers) { + ctx.response.headers.set(key, value); + } + + if (response.body) { + ctx.response.body = await response.text(); + } + }; +} \ No newline at end of file diff --git a/src/server_oak.ts b/src/server_oak.ts new file mode 100644 index 0000000..2ff4c62 --- /dev/null +++ b/src/server_oak.ts @@ -0,0 +1,72 @@ +import { Application, Router, Context } from "@oak/oak"; +import type { AppConfig } from "./config.ts"; +import { rateLimitMiddleware } from "./middleware.ts"; +import { createRpcHandler } from "./rpc_handler_oak.ts"; + +const SERVER_PORT = 8000; + +/** + * Creates and configures CORS middleware for Oak application. + */ +function corsMiddleware() { + return async (ctx: Context, next: () => Promise) => { + if (ctx.request.method === "OPTIONS") { + ctx.response.status = 204; + ctx.response.headers.set("Access-Control-Allow-Origin", "*"); + ctx.response.headers.set("Access-Control-Allow-Methods", "POST, OPTIONS"); + ctx.response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization"); + ctx.response.headers.set("Access-Control-Max-Age", "86400"); + return; + } + + await next(); + ctx.response.headers.set("Access-Control-Allow-Origin", "*"); + }; +} + +/** + * Creates and configures logging middleware for Oak application. + */ +function loggingMiddleware() { + return async (ctx: Context, next: () => Promise) => { + const start = Date.now(); + await next(); + const ms = Date.now() - start; + console.log(`${ctx.request.method} ${ctx.request.url.pathname} - ${ctx.response.status} - ${ms}ms`); + }; +} + +/** + * Starts the RPC proxy server using Oak framework. + * + * @param appConfig The loaded application configuration. + */ +export function startServer(appConfig: AppConfig): void { + const app = new Application(); + const router = new Router(); + + // Add global middleware + app.use(loggingMiddleware()); + app.use(corsMiddleware()); + + // Health check endpoint + router.get("/health", (ctx) => { + ctx.response.status = 200; + ctx.response.headers.set("Content-Type", "text/plain"); + ctx.response.body = "OK"; + }); + + // RPC endpoints - handle any network slug with rate limiting and RPC forwarding + router.post("/:network", + rateLimitMiddleware(appConfig.rateLimit), + createRpcHandler(appConfig) + ); + + // Add router routes to app + app.use(router.routes()); + app.use(router.allowedMethods()); + + console.log(`🚀 Starting RPC proxy server with Oak on http://localhost:${SERVER_PORT}`); + + app.listen({ port: SERVER_PORT }); +} \ No newline at end of file diff --git a/tests/config_test.ts b/tests/config_test.ts new file mode 100644 index 0000000..51a1b5a --- /dev/null +++ b/tests/config_test.ts @@ -0,0 +1,71 @@ +import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { loadAppConfig } from "../src/config.ts"; + +Deno.test("Config - should load valid configuration", () => { + // Set up environment variables for testing + const originalConfig = Deno.env.get("RPC_CONFIG"); + const originalToken = Deno.env.get("INTERNAL_AUTH_TOKEN"); + const originalRateLimit = Deno.env.get("PUBLIC_RATE_LIMIT_ENABLED"); + + try { + // Set test configuration + Deno.env.set("RPC_CONFIG", JSON.stringify({ + "mainnet": [ + { "url": "https://eth-mainnet.example.com" }, + { "url": "https://backup-mainnet.example.com", "authToken": "Bearer secret" } + ], + "sepolia": [ + { "url": "https://eth-sepolia.example.com" } + ] + })); + Deno.env.set("INTERNAL_AUTH_TOKEN", "test-bypass-token"); + Deno.env.set("PUBLIC_RATE_LIMIT_ENABLED", "true"); + + const config = loadAppConfig(); + + assertEquals(config !== null, true); + assertEquals(Object.keys(config!.rpc).length, 2); + assertEquals(config!.rateLimit.bypassToken, "test-bypass-token"); + assertEquals(config!.rateLimit.enabled, true); + } finally { + // Restore original environment + if (originalConfig) { + Deno.env.set("RPC_CONFIG", originalConfig); + } else { + Deno.env.delete("RPC_CONFIG"); + } + if (originalToken) { + Deno.env.set("INTERNAL_AUTH_TOKEN", originalToken); + } else { + Deno.env.delete("INTERNAL_AUTH_TOKEN"); + } + if (originalRateLimit) { + Deno.env.set("PUBLIC_RATE_LIMIT_ENABLED", originalRateLimit); + } else { + Deno.env.delete("PUBLIC_RATE_LIMIT_ENABLED"); + } + } +}); + +Deno.test("Config - should reject invalid configuration", () => { + const originalConfig = Deno.env.get("RPC_CONFIG"); + + try { + // Set invalid configuration (missing required fields) + Deno.env.set("RPC_CONFIG", JSON.stringify({ + "mainnet": [ + { "invalidField": "not-a-url" } // Missing 'url' field + ] + })); + + const config = loadAppConfig(); + + assertEquals(config, null); + } finally { + if (originalConfig) { + Deno.env.set("RPC_CONFIG", originalConfig); + } else { + Deno.env.delete("RPC_CONFIG"); + } + } +}); \ No newline at end of file diff --git a/tests/integration_test.ts b/tests/integration_test.ts new file mode 100644 index 0000000..173253e --- /dev/null +++ b/tests/integration_test.ts @@ -0,0 +1,184 @@ +import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { handleRpcRequest } from "../src/rpc_handler.ts"; +import type { AppConfig, RateLimitConfig, RpcConfig } from "../src/config.ts"; + +// Integration tests for the complete RPC handler +Deno.test("Integration - should handle valid RPC request with authentication", async () => { + const mockRpcConfig: RpcConfig = { + "mainnet": [ + { url: "https://jsonrpc.test/invalid" }, // This will fail, testing fallback + { url: "https://httpbin.org/post" } // This should work + ] + }; + + const rateLimitConfig: RateLimitConfig = { + enabled: true, + rpm: 60, + bypassToken: "test-token" + }; + + const appConfig: AppConfig = { + rpc: mockRpcConfig, + rateLimit: rateLimitConfig + }; + + const requestBody = { + jsonrpc: "2.0", + method: "eth_getBalance", + params: ["0x1234567890abcdef", "latest"], + id: 1 + }; + + const request = new Request("http://localhost:8000/mainnet", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer test-token" + }, + body: JSON.stringify(requestBody) + }); + + const mockInfo: Deno.ServeHandlerInfo = { + remoteAddr: { + transport: "tcp", + hostname: "192.168.1.100", + port: 12345 + } + }; + + const response = await handleRpcRequest(request, mockInfo, appConfig); + + // Should succeed due to trusted auth token + assertEquals(response.status < 500, true); +}); + +Deno.test("Integration - should apply rate limiting for public requests", async () => { + const mockRpcConfig: RpcConfig = { + "mainnet": [ + { url: "https://httpbin.org/post" } + ] + }; + + const rateLimitConfig: RateLimitConfig = { + enabled: true, + rpm: 1, // Very low limit for testing + bypassToken: "test-token" + }; + + const appConfig: AppConfig = { + rpc: mockRpcConfig, + rateLimit: rateLimitConfig + }; + + const requestBody = { + jsonrpc: "2.0", + method: "eth_getBalance", + params: ["0x1234567890abcdef", "latest"], + id: 1 + }; + + const request = new Request("http://localhost:8000/mainnet", { + method: "POST", + headers: { + "Content-Type": "application/json" + // No auth token + }, + body: JSON.stringify(requestBody) + }); + + const mockInfo: Deno.ServeHandlerInfo = { + remoteAddr: { + transport: "tcp", + hostname: "192.168.1.100", + port: 12345 + } + }; + + // First request should pass + const response1 = await handleRpcRequest(request, mockInfo, appConfig); + + // Second request should be rate limited + const response2 = await handleRpcRequest(request, mockInfo, appConfig); + + assertEquals(response2.status, 429); +}); + +Deno.test("Integration - should handle unknown network", async () => { + const mockRpcConfig: RpcConfig = { + "mainnet": [ + { url: "https://httpbin.org/post" } + ] + }; + + const rateLimitConfig: RateLimitConfig = { + enabled: false, + rpm: 60, + bypassToken: null + }; + + const appConfig: AppConfig = { + rpc: mockRpcConfig, + rateLimit: rateLimitConfig + }; + + const request = new Request("http://localhost:8000/unknownnet", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "test" }) + }); + + const mockInfo: Deno.ServeHandlerInfo = { + remoteAddr: { + transport: "tcp", + hostname: "192.168.1.100", + port: 12345 + } + }; + + const response = await handleRpcRequest(request, mockInfo, appConfig); + + assertEquals(response.status, 404); + assertEquals(await response.text(), "Network not configured: unknownnet"); +}); + +Deno.test("Integration - should handle invalid JSON in request", async () => { + const mockRpcConfig: RpcConfig = { + "mainnet": [ + { url: "https://httpbin.org/post" } + ] + }; + + const rateLimitConfig: RateLimitConfig = { + enabled: false, + rpm: 60, + bypassToken: null + }; + + const appConfig: AppConfig = { + rpc: mockRpcConfig, + rateLimit: rateLimitConfig + }; + + const request = new Request("http://localhost:8000/mainnet", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: "invalid json" + }); + + const mockInfo: Deno.ServeHandlerInfo = { + remoteAddr: { + transport: "tcp", + hostname: "192.168.1.100", + port: 12345 + } + }; + + const response = await handleRpcRequest(request, mockInfo, appConfig); + + assertEquals(response.status, 400); + assertEquals((await response.text()).includes("Invalid JSON"), true); +}); \ No newline at end of file From 9136950788707404e7913ad00b562db82e81b019 Mon Sep 17 00:00:00 2001 From: Georgios Jason Efstathiou Date: Fri, 26 Sep 2025 13:23:37 +0200 Subject: [PATCH 4/4] wip --- deno.json | 3 +- deno.lock | 155 ++++++++++++++++++++++++++++++++++++++ src/auth.ts | 6 +- src/middleware.ts | 5 +- src/rpc_handler_oak.ts | 8 +- tests/auth_test.ts | 7 +- tests/integration_test.ts | 14 ++-- tests/rpc_client_test.ts | 12 ++- 8 files changed, 183 insertions(+), 27 deletions(-) create mode 100644 deno.lock diff --git a/deno.json b/deno.json index c933057..7f70c3c 100644 --- a/deno.json +++ b/deno.json @@ -6,6 +6,7 @@ "imports": { "@oak/oak": "https://deno.land/x/oak@v17.1.2/mod.ts", "dotenv": "https://deno.land/std@0.224.0/dotenv/mod.ts", - "zod": "https://deno.land/x/zod@v3.23.4/mod.ts" + "zod": "https://deno.land/x/zod@v3.23.4/mod.ts", + "std/assert": "https://deno.land/std@0.224.0/assert/mod.ts" } } diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..6f98ddd --- /dev/null +++ b/deno.lock @@ -0,0 +1,155 @@ +{ + "version": "5", + "specifiers": { + "jsr:@oak/commons@1": "1.0.1", + "jsr:@std/assert@1": "1.0.13", + "jsr:@std/bytes@1": "1.0.6", + "jsr:@std/bytes@^1.0.2": "1.0.6", + "jsr:@std/crypto@1": "1.0.5", + "jsr:@std/encoding@1": "1.0.10", + "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/http@1": "1.0.16", + "jsr:@std/io@0.224": "0.224.9", + "jsr:@std/media-types@1": "1.1.0", + "jsr:@std/path@1": "1.0.9", + "npm:@types/node@*": "24.2.0", + "npm:path-to-regexp@6.2.1": "6.2.1" + }, + "jsr": { + "@oak/commons@1.0.1": { + "integrity": "889ff210f0b4292591721be07244ecb1b5c118742f5273c70cf30d7cd4184d0c", + "dependencies": [ + "jsr:@std/assert", + "jsr:@std/bytes@1", + "jsr:@std/crypto", + "jsr:@std/encoding@1", + "jsr:@std/http", + "jsr:@std/media-types" + ] + }, + "@std/assert@1.0.13": { + "integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29" + }, + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" + }, + "@std/crypto@1.0.5": { + "integrity": "0dcfbb319fe0bba1bd3af904ceb4f948cde1b92979ec1614528380ed308a3b40" + }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/http@1.0.16": { + "integrity": "80c8d08c4bfcf615b89978dcefb84f7e880087cf3b6b901703936f3592a06933", + "dependencies": [ + "jsr:@std/encoding@^1.0.10" + ] + }, + "@std/io@0.224.9": { + "integrity": "4414664b6926f665102e73c969cfda06d2c4c59bd5d0c603fd4f1b1c840d6ee3", + "dependencies": [ + "jsr:@std/bytes@^1.0.2" + ] + }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/path@1.0.9": { + "integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e" + } + }, + "npm": { + "@types/node@24.2.0": { + "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", + "dependencies": [ + "undici-types" + ] + }, + "path-to-regexp@6.2.1": { + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==" + }, + "undici-types@7.10.0": { + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" + } + }, + "remote": { + "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", + "https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7", + "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", + "https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", + "https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff", + "https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46", + "https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b", + "https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c", + "https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", + "https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68", + "https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3", + "https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7", + "https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29", + "https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a", + "https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a", + "https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8", + "https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693", + "https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", + "https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5", + "https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8", + "https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb", + "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", + "https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68", + "https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3", + "https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", + "https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", + "https://deno.land/std@0.224.0/dotenv/mod.ts": "0180eaeedaaf88647318811cdaa418cc64dc51fb08354f91f5f480d0a1309f7d", + "https://deno.land/std@0.224.0/dotenv/parse.ts": "09977ff88dfd1f24f9973a338f0f91bbdb9307eb5ff6085446e7c423e4c7ba0c", + "https://deno.land/std@0.224.0/dotenv/stringify.ts": "275da322c409170160440836342eaa7cf012a1d11a7e700d8ca4e7f2f8aa4615", + "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", + "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", + "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", + "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e", + "https://deno.land/x/oak@v17.1.2/application.ts": "2bcc73b3f22a193554c9958f7080ea635db25594d25ff7944021fca5bf74adba", + "https://deno.land/x/oak@v17.1.2/body.ts": "0eb7ab9df44d1b79933463d596b5e1a4f0991c94cff591861e58a413bda3f3db", + "https://deno.land/x/oak@v17.1.2/context.ts": "345cfdaa5a2310558ee0863f2fba5f9ba648188412b16ce342c33266c085f5d3", + "https://deno.land/x/oak@v17.1.2/deps.ts": "b778c76e91d6d2afe6124981212b6dc3d0b195739ea01857a45396dc78812b59", + "https://deno.land/x/oak@v17.1.2/http_server_bun.ts": "cb3a66c735cd0533c4c3776b37ae627ab42344c82f91dff63e3030d9656fd3a0", + "https://deno.land/x/oak@v17.1.2/http_server_native.ts": "3bea00ebb9638203d2449fbf9a14a6b87f119bd45012f13282ececdf7b4c4242", + "https://deno.land/x/oak@v17.1.2/http_server_native_request.ts": "a4da6f4939736e6323720db2d4b0d19ff2e83f02e52ab1eea9ae80f2c047fa56", + "https://deno.land/x/oak@v17.1.2/http_server_node.ts": "9bb5291c15305b297fd634aa4c6b1d5054368f4b7a171d7c7c302c73eb2489ed", + "https://deno.land/x/oak@v17.1.2/middleware.ts": "4170180fe5009d2581a0bdc995e5953b90ccb5b1c3767f3eae8a4fe238b8bd81", + "https://deno.land/x/oak@v17.1.2/middleware/etag.ts": "eb3cb3a8862ca1990ce80b7d1c3f142c866b22d34c2f785a0e8b46bc34b91f7d", + "https://deno.land/x/oak@v17.1.2/middleware/proxy.ts": "104890bd3990fa68618c36141d56aff5f95a51deac57ce8b8b0c7a14a8fd2ea4", + "https://deno.land/x/oak@v17.1.2/middleware/serve.ts": "efceebd70afb73bcabe0a6a8981f3d8474a2f2f30e85b46761aee49e81bd9d6a", + "https://deno.land/x/oak@v17.1.2/mod.ts": "38e53e01e609583e843f3e2b2677de9872d23d68939ce0de85b402e7a8db01a7", + "https://deno.land/x/oak@v17.1.2/node_shims.ts": "4db1569b2b79b73f37c4d947f4aaa50a93e266d48fe67601c8a31af17a28884d", + "https://deno.land/x/oak@v17.1.2/request.ts": "1e7f2c338cd6889b616bbdc9e2062eac27acbffde05c685adfb1c60ecc80682a", + "https://deno.land/x/oak@v17.1.2/response.ts": "75724db76f2dd782827f2dcaa00e4e1732709399bad2458c787fed2c95c4f8be", + "https://deno.land/x/oak@v17.1.2/router.ts": "d625307016e5db92a9ff187d9917093cb441a8c442c0d78d8dbdbd9575816b72", + "https://deno.land/x/oak@v17.1.2/send.ts": "340676bbed632dd11194b930467a4e1aec64199b08328e6394cb7669b64c98a6", + "https://deno.land/x/oak@v17.1.2/testing.ts": "cbefaf9d21452e829999eda9b99b920df7fde3f702528a484a4433ffed20a9cf", + "https://deno.land/x/oak@v17.1.2/types.ts": "cd4ccd3e182d0cba2117cd27f560267970470ab9c0ff6556cadd73f605193be7", + "https://deno.land/x/oak@v17.1.2/utils/clone_state.ts": "cf8989ddd56816b36ada253ae0acdbd46cdf3d68dbe674d2b66c46640fab3500", + "https://deno.land/x/oak@v17.1.2/utils/consts.ts": "137c4f73479f5e98a13153b9305f06f0d85df3cf2aacad2c9f82d4c1f3a2f105", + "https://deno.land/x/oak@v17.1.2/utils/create_promise_with_resolvers.ts": "de99e9a998162b929a011f8873eaf0895cf4742492b3ce6f6866d39217342523", + "https://deno.land/x/oak@v17.1.2/utils/decode.ts": "2fd843f1217872318c339006dad266d62cdb99bff84bb6adef8a4b86269f51ce", + "https://deno.land/x/oak@v17.1.2/utils/decode_component.ts": "d3e2c40ecdd2fdb79761c6e9ae224cf01a4643f7c5f4c1e0b69698d43025261b", + "https://deno.land/x/oak@v17.1.2/utils/encode_url.ts": "c0ed6b318eb9523adeebba32eb9acd059c0f94d3511b2b9e3b024722d1b3dfb8", + "https://deno.land/x/oak@v17.1.2/utils/resolve_path.ts": "aa39d54a003b38fee55f340a0cba3f93a7af85b8ddd5fbfb049a98fc0109b36d", + "https://deno.land/x/oak@v17.1.2/utils/streams.ts": "3da73b94681f8d27a82cc67df3f91090ec0bd6c3e9ab957af588d41ab585d923", + "https://deno.land/x/oak@v17.1.2/utils/type_guards.ts": "a8dbb5ab7424f0355b121537d2454f927e0ca9949262fb67ac4fbefbd5880313", + "https://deno.land/x/zod@v3.23.4/ZodError.ts": "528da200fbe995157b9ae91498b103c4ef482217a5c086249507ac850bd78f52", + "https://deno.land/x/zod@v3.23.4/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef", + "https://deno.land/x/zod@v3.23.4/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe", + "https://deno.land/x/zod@v3.23.4/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c", + "https://deno.land/x/zod@v3.23.4/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7", + "https://deno.land/x/zod@v3.23.4/helpers/parseUtil.ts": "c14814d167cc286972b6e094df88d7d982572a08424b7cd50f862036b6fcaa77", + "https://deno.land/x/zod@v3.23.4/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7", + "https://deno.land/x/zod@v3.23.4/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e", + "https://deno.land/x/zod@v3.23.4/helpers/util.ts": "3301a69867c9e589ac5b3bc4d7a518b5212858cd6a25e8b02d635c9c32ba331c", + "https://deno.land/x/zod@v3.23.4/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268", + "https://deno.land/x/zod@v3.23.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c", + "https://deno.land/x/zod@v3.23.4/mod.ts": "ec6e2b1255c1a350b80188f97bd0a6bac45801bb46fc48f50b9763aa66046039", + "https://deno.land/x/zod@v3.23.4/types.ts": "7641b9850663f368f568c243eac418fa19834e78b31a866c73772116caa53e7d" + } +} diff --git a/src/auth.ts b/src/auth.ts index 22242dc..217f422 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -16,14 +16,14 @@ export interface AuthResult { export function authenticateRequest( authHeader: string | null, rateLimitConfig: RateLimitConfig, - remoteAddr: Deno.NetAddr + remoteAddr: Deno.Addr, ): AuthResult { let isTrusted = false; let clientIp: string | undefined; // Extract client IP for rate limiting if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") { - clientIp = remoteAddr.hostname; + clientIp = (remoteAddr as Deno.NetAddr).hostname; } // Check for bypass token authentication @@ -39,4 +39,4 @@ export function authenticateRequest( } return { isTrusted, clientIp }; -} \ No newline at end of file +} diff --git a/src/middleware.ts b/src/middleware.ts index a005633..c8392fd 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -27,8 +27,7 @@ export function rateLimitMiddleware(rateLimitConfig: RateLimitConfig) { const clientIp = ctx.request.ip; if (clientIp && !checkRateLimit(clientIp, rateLimitConfig)) { - const networkSlug = ctx.params?.network || "unknown"; - console.warn(`[RateLimit] IP ${clientIp} exceeded limit of ${rateLimitConfig.rpm} RPM for network ${networkSlug}.`); + console.warn(`[RateLimit] IP ${clientIp} exceeded limit of ${rateLimitConfig.rpm} RPM.`); ctx.response.status = 429; ctx.response.headers.set("Retry-After", "60"); @@ -44,4 +43,4 @@ export function rateLimitMiddleware(rateLimitConfig: RateLimitConfig) { await next(); }; -} \ No newline at end of file +} diff --git a/src/rpc_handler_oak.ts b/src/rpc_handler_oak.ts index fd11b8d..cd1167c 100644 --- a/src/rpc_handler_oak.ts +++ b/src/rpc_handler_oak.ts @@ -1,6 +1,6 @@ -import { Context } from "@oak/oak"; +import { RouterContext } from "@oak/oak"; import type { AppConfig } from "./config.ts"; -import { extractNetworkSlug, isNetworkConfigured } from "./routing.ts"; +import { isNetworkConfigured } from "./routing.ts"; import { forwardWithFallback } from "./rpc_client.ts"; /** @@ -11,7 +11,7 @@ import { forwardWithFallback } from "./rpc_client.ts"; * @returns Oak middleware function */ export function createRpcHandler(appConfig: AppConfig) { - return async (ctx: Context) => { + return async (ctx: RouterContext<"/:network">) => { const { rpc: rpcConfig } = appConfig; // Extract network slug from URL params (Oak handles this automatically) @@ -85,4 +85,4 @@ export function createRpcHandler(appConfig: AppConfig) { ctx.response.body = await response.text(); } }; -} \ No newline at end of file +} diff --git a/tests/auth_test.ts b/tests/auth_test.ts index 6f180d5..1139ea1 100644 --- a/tests/auth_test.ts +++ b/tests/auth_test.ts @@ -78,10 +78,9 @@ Deno.test("Auth - should handle non-TCP transport", () => { bypassToken: "valid-secret-token" }; - const mockRemoteAddr: Deno.NetAddr = { + const mockRemoteAddr: Deno.UnixAddr = { transport: "unix", - hostname: "", - port: 0 + path: "/tmp/socket" }; const result = authenticateRequest( @@ -92,4 +91,4 @@ Deno.test("Auth - should handle non-TCP transport", () => { assertEquals(result.isTrusted, false); assertEquals(result.clientIp, undefined); -}); \ No newline at end of file +}); diff --git a/tests/integration_test.ts b/tests/integration_test.ts index 173253e..5d9dba3 100644 --- a/tests/integration_test.ts +++ b/tests/integration_test.ts @@ -43,7 +43,8 @@ Deno.test("Integration - should handle valid RPC request with authentication", a transport: "tcp", hostname: "192.168.1.100", port: 12345 - } + }, + completed: Promise.resolve() }; const response = await handleRpcRequest(request, mockInfo, appConfig); @@ -91,7 +92,8 @@ Deno.test("Integration - should apply rate limiting for public requests", async transport: "tcp", hostname: "192.168.1.100", port: 12345 - } + }, + completed: Promise.resolve() }; // First request should pass @@ -134,7 +136,8 @@ Deno.test("Integration - should handle unknown network", async () => { transport: "tcp", hostname: "192.168.1.100", port: 12345 - } + }, + completed: Promise.resolve() }; const response = await handleRpcRequest(request, mockInfo, appConfig); @@ -174,11 +177,12 @@ Deno.test("Integration - should handle invalid JSON in request", async () => { transport: "tcp", hostname: "192.168.1.100", port: 12345 - } + }, + completed: Promise.resolve() }; const response = await handleRpcRequest(request, mockInfo, appConfig); assertEquals(response.status, 400); assertEquals((await response.text()).includes("Invalid JSON"), true); -}); \ No newline at end of file +}); diff --git a/tests/rpc_client_test.ts b/tests/rpc_client_test.ts index a28457d..c166047 100644 --- a/tests/rpc_client_test.ts +++ b/tests/rpc_client_test.ts @@ -1,4 +1,4 @@ -import { assertEquals, assertExists } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { assertEquals, assertExists } from 'std/assert'; import { forwardToRpcEndpoint, forwardWithFallback } from "../src/rpc_client.ts"; // Mock RPC server setup for testing @@ -34,18 +34,16 @@ function startMockRpcServer(behavior: "success" | "error" | "timeout" | "invalid case "timeout": // Never respond to simulate timeout - await new Promise(() => {}); - break; + return new Promise(() => {}); case "invalid-json": return new Response("invalid json response", { status: 200, headers: { "Content-Type": "text/plain" } }); - - default: - return new Response("Internal Server Error", { status: 500 }); } + + return new Response("Internal Server Error", { status: 500 }); }); resolve({ @@ -198,4 +196,4 @@ Deno.test("RPC Client - Single endpoint timeout handling", async () => { } finally { timeoutServer.close(); } -}); \ No newline at end of file +});