diff --git a/README.md b/README.md index 28d7f592..55c78208 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ Euler Lite provides all the core functionality of Euler Finance in a customizabl - **Rewards**: Participate in Merkl, Incentra, and Fuul reward programs - **Multi-chain Support**: Connect to multiple EVM-compatible networks +> The only API surface this app commits to keeping stable is documented in [docs/PUBLIC_API.md](docs/PUBLIC_API.md). Anything else under `/api/internal/*` is implementation detail. + ## Prerequisites - **Node.js** 24+ (recommended: 24.14.1) @@ -49,7 +51,7 @@ cp .env.example .env | ----------------- | ----------------------------- | ------------------------------------- | | `EULER_API_URL` | — | Euler API (tokens, prices, logos) | | `SWAP_API_URL` | — | Euler swap API | -| `PYTH_HERMES_URL` | `https://hermes.pyth.network` | Pyth oracle endpoint (server-only, proxied via `/api/pyth/updates`) | +| `PYTH_HERMES_URL` | `https://hermes.pyth.network` | Pyth oracle endpoint (server-only, proxied via `/api/internal/pyth/updates`) | > **Doppler compatibility:** If your secret manager injects `NUXT_PUBLIC_*` prefixed names (e.g. `NUXT_PUBLIC_EULER_API_URL`), the app accepts both forms automatically. @@ -332,7 +334,7 @@ Before deploying: ### Token logos not loading - Verify `EULER_API_URL` is set correctly. If using Doppler, ensure the env var name matches (`EULER_API_URL` or `NUXT_PUBLIC_EULER_API_URL`). -- Token data is fetched server-side via `/api/token-list` which aggregates Euler API, DefiLlama, and Uniswap sources with fallback. Check server logs for upstream failures. +- Token data is fetched server-side via `/api/internal/token-list` which aggregates Euler API, DefiLlama, and Uniswap sources with fallback. Check server logs for upstream failures. ### Build Errors diff --git a/composables/useAccountPositions.ts b/composables/useAccountPositions.ts index 23d3bfb0..2f3311c1 100644 --- a/composables/useAccountPositions.ts +++ b/composables/useAccountPositions.ts @@ -124,7 +124,7 @@ const finalizeRefreshCycle = () => { // Best-effort lookup; does not block capture. Used only to classify Sentry // events by vault type. The async category fetch works even when RPC is the -// failure mode, since `/api/vault-categories` is subgraph-backed. +// failure mode, since `/api/internal/vault-categories` is subgraph-backed. const resolveVaultType = async ( vault: string, ): Promise<'evk' | 'earn' | 'securitize' | 'unknown'> => { diff --git a/composables/useBrevis.ts b/composables/useBrevis.ts index 0f3235a6..a18e4415 100644 --- a/composables/useBrevis.ts +++ b/composables/useBrevis.ts @@ -25,7 +25,7 @@ const inFlightBrevis = createInFlightDedup const fetchBrevisCampaignsProxy = (chainId: number): Promise => inFlightBrevis.run(chainId, () => - $fetch('/api/rewards/brevis', { query: { chainId } })) + $fetch('/api/internal/rewards/brevis', { query: { chainId } })) const ACTION_MAP: Record = { EULER_BORROW: CampaignAction.BORROW, diff --git a/composables/useEulerAddresses.ts b/composables/useEulerAddresses.ts index 708d1c90..9a926cf7 100644 --- a/composables/useEulerAddresses.ts +++ b/composables/useEulerAddresses.ts @@ -114,7 +114,7 @@ export const useEulerAddresses = () => { error.value = null try { - const response = await fetch('/api/euler-chains') + const response = await fetch('/api/internal/euler-chains') if (!response.ok) { throw new Error(`Failed to fetch Euler config: ${response.statusText}`) } diff --git a/composables/useEulerLabels.ts b/composables/useEulerLabels.ts index 09f1649f..5f61ab6e 100644 --- a/composables/useEulerLabels.ts +++ b/composables/useEulerLabels.ts @@ -65,7 +65,7 @@ const loadOracleAdapter = async (chainId: number, oracleAddress: string) => { loadingAdapters.add(key) try { - const res = await axios.get('/api/oracle-adapter', { params: { chainId, address: checksummed } }) + const res = await axios.get('/api/internal/oracle-adapter', { params: { chainId, address: checksummed } }) const meta = normalizeOracleAdapters([res.data]) safeAssign(oracleAdapters, meta) return oracleAdapters[key] @@ -101,7 +101,7 @@ const loadAllOracleAdapters = async (chainId: number): Promise => { const promise = (async () => { try { - const res = await axios.get('/api/oracle-adapters', { params: { chainId } }) + const res = await axios.get('/api/internal/oracle-adapters', { params: { chainId } }) const meta = normalizeOracleAdapters(res.data) safeAssign(oracleAdapters, meta) bulkLoadedAdapterChains.set(chainId, Date.now()) @@ -167,11 +167,11 @@ export const useEulerLabels = () => { verifiedVaultAddresses.value = [] const [productRes, entitiesRes, earnRes, pointsRes, assetsRes] = await Promise.allSettled([ - axios.get('/api/labels/products.json', { params: { chainId } }), - axios.get('/api/labels/entities.json', { params: { chainId } }), - axios.get('/api/labels/earn-vaults.json', { params: { chainId } }), - axios.get('/api/labels/points.json', { params: { chainId } }), - axios.get('/api/labels/assets.json', { params: { chainId } }), + axios.get('/api/internal/labels/products.json', { params: { chainId } }), + axios.get('/api/internal/labels/entities.json', { params: { chainId } }), + axios.get('/api/internal/labels/earn-vaults.json', { params: { chainId } }), + axios.get('/api/internal/labels/points.json', { params: { chainId } }), + axios.get('/api/internal/labels/assets.json', { params: { chainId } }), ]) if (productRes.status === 'fulfilled') { diff --git a/composables/useFuul.ts b/composables/useFuul.ts index cc02cc4c..183f904e 100644 --- a/composables/useFuul.ts +++ b/composables/useFuul.ts @@ -48,7 +48,7 @@ const inFlightFuul = createInFlightDedup() const fetchFuulProxy = (chainId: number): Promise => inFlightFuul.run(chainId, () => - $fetch('/api/rewards/fuul', { query: { chainId } })) + $fetch('/api/internal/rewards/fuul', { query: { chainId } })) export const useFuul = () => { const { address: wagmiAddress, chain: wagmiChain } = useWagmi() diff --git a/composables/useIntrinsicApy.ts b/composables/useIntrinsicApy.ts index e793ea8c..90912f0b 100644 --- a/composables/useIntrinsicApy.ts +++ b/composables/useIntrinsicApy.ts @@ -29,7 +29,7 @@ export const useIntrinsicApy = () => { try { isLoading.value = true - const data = await $fetch>('/api/intrinsic-apy', { + const data = await $fetch>('/api/internal/intrinsic-apy', { query: { chainId: chainId.value }, }) intrinsicApyByAddress.value = data ?? {} diff --git a/composables/useMerkl.ts b/composables/useMerkl.ts index 2cc45fcd..664a676e 100644 --- a/composables/useMerkl.ts +++ b/composables/useMerkl.ts @@ -78,7 +78,7 @@ const merklOpportunityUrl = ( return `https://app.merkl.xyz/opportunities/${encodeURIComponent(chainSlug)}/${type}/${encodeURIComponent(opportunity.identifier)}` } -// Public campaign data flows through `/api/rewards/merkl`, which warms the +// Public campaign data flows through `/api/internal/rewards/merkl`, which warms the // Merkl opportunity types server-side and returns one CDN-cacheable // GET. User-specific /users/{addr}/rewards stays direct (per-wallet data // not safe to put on a shared cache). @@ -90,7 +90,7 @@ const inFlightMerkl = createInFlightDedup() const fetchMerklProxy = (chainId: number): Promise => inFlightMerkl.run(chainId, () => - $fetch('/api/rewards/merkl', { query: { chainId } }) + $fetch('/api/internal/rewards/merkl', { query: { chainId } }) .catch((e) => { logWarn('merkl/proxy', e) return null diff --git a/composables/useRpcClient/index.ts b/composables/useRpcClient/index.ts index c3ad5661..09c78f10 100644 --- a/composables/useRpcClient/index.ts +++ b/composables/useRpcClient/index.ts @@ -13,9 +13,9 @@ export const useRpcClient = (): { if (!chainId.value) return '' if (import.meta.server) { const requestUrl = useRequestURL() - return `${requestUrl.origin}/api/rpc/${chainId.value}` + return `${requestUrl.origin}/api/internal/rpc/${chainId.value}` } - return `/api/rpc/${chainId.value}` + return `/api/internal/rpc/${chainId.value}` }) const client = computed(() => { diff --git a/composables/useTenderlySimulation.ts b/composables/useTenderlySimulation.ts index 45483292..1bdd63d5 100644 --- a/composables/useTenderlySimulation.ts +++ b/composables/useTenderlySimulation.ts @@ -58,7 +58,7 @@ export const useTenderlySimulation = () => { isSimulating.value = true try { - const response = await $fetch('/api/tenderly/simulate', { + const response = await $fetch('/api/internal/tenderly/simulate', { method: 'POST', body: params, }) @@ -78,7 +78,7 @@ export const useTenderlySimulation = () => { const fetchEnabled = async (): Promise => { try { - const { enabled } = await $fetch<{ enabled: boolean }>('/api/tenderly/status') + const { enabled } = await $fetch<{ enabled: boolean }>('/api/internal/tenderly/status') return enabled } catch { diff --git a/composables/useTokenList.ts b/composables/useTokenList.ts index e9b9767d..c65553de 100644 --- a/composables/useTokenList.ts +++ b/composables/useTokenList.ts @@ -95,7 +95,7 @@ const loadTokenList = async (forceRefresh = false) => { isLoading.value = true isLoaded.value = false - const res = await axios.get('/api/token-list', { params: { chainId } }) + const res = await axios.get('/api/internal/token-list', { params: { chainId } }) if (guard.isStale(gen)) return const tokens: TokenListEntry[] = res.data?.tokens || [] diff --git a/composables/useTosData.ts b/composables/useTosData.ts index 47ef9081..b6173711 100644 --- a/composables/useTosData.ts +++ b/composables/useTosData.ts @@ -20,7 +20,7 @@ export async function getTosData(): Promise { const { tosUrl } = useDeployConfig() - fetchPromise = fetch('/api/tos') + fetchPromise = fetch('/api/internal/tos') .then((response) => { if (!response.ok) { throw new Error(`Failed to fetch ToS: ${response.status} ${response.statusText}`) diff --git a/composables/useVaults.ts b/composables/useVaults.ts index 5eb26284..63251248 100644 --- a/composables/useVaults.ts +++ b/composables/useVaults.ts @@ -432,7 +432,7 @@ const hydrateFromServer = async (targetChainId: number, generation: number): Pro // server-side so this is usually two fast hits. Categorization provides // the escrow address set previously baked into the snapshot payload. const [wire, categories] = await Promise.all([ - $fetch('/api/vaults', { query: { chainId: targetChainId } }), + $fetch('/api/internal/vaults', { query: { chainId: targetChainId } }), fetchChainVaultCategories(), ]) if (loadGeneration.value !== generation) return false diff --git a/docs/PUBLIC_API.md b/docs/PUBLIC_API.md new file mode 100644 index 00000000..5b79bf9e --- /dev/null +++ b/docs/PUBLIC_API.md @@ -0,0 +1,22 @@ +# Public API + +These endpoints are the only stable contract this app exposes. Anything under `/api/internal/` is implementation detail and may change without notice — including being renamed, restructured, returning different fields, or being removed entirely. + +## Stable endpoints + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/api/public/is-known?chainId=&address=` | Returns whether the vault address is part of the verified catalogue. | +| `GET` | `/api/public/metadata?chainId=&address=` | Returns vault metadata for the verified catalogue. | + +Both endpoints serve `Access-Control-Allow-Origin: *` so they're callable from any origin. + +## Non-public endpoints + +Everything else under `/api/` lives at `/api/internal/*` and is reserved for the Euler Lite app itself. Calling them from outside is unsupported: + +- Cross-origin browser requests are rejected unless the origin is on the app's allow-list. +- Non-browser requests (curl, server-side fetch) without an `Origin` header are rejected with `403 Forbidden`. +- Every internal response carries `X-API-Stability: internal; may-break-without-notice` for the avoidance of doubt. + +The shape, presence, and behaviour of these endpoints may change between releases. If you need data they expose, open a request for an addition to the public surface above rather than relying on the internal namespace. diff --git a/entities/vault/factory.ts b/entities/vault/factory.ts index 1630c8e6..33bc37e7 100644 --- a/entities/vault/factory.ts +++ b/entities/vault/factory.ts @@ -74,7 +74,7 @@ export const fetchChainVaultCategories = async (): Promise => { const existing = chainCategoriesInFlight.get(chainId) if (existing) return (await existing) ?? emptyCategories() - const promise = $fetch('/api/vault-categories', { query: { chainId } }) + const promise = $fetch('/api/internal/vault-categories', { query: { chainId } }) .then((data) => { const categories = { evk: data?.evk ?? [], @@ -121,7 +121,7 @@ export const fetchVaultCategory = async (address: string): Promise('/api/vault-categories', { + const promise = $fetch<{ category: VaultCategory | null }>('/api/internal/vault-categories', { query: { chainId, address }, }) .then((data) => { diff --git a/entities/vault/loader.ts b/entities/vault/loader.ts index 661644c7..bbae0ea3 100644 --- a/entities/vault/loader.ts +++ b/entities/vault/loader.ts @@ -12,7 +12,7 @@ import { summarizeViemError } from '~/utils/viem-errors' * fetches it separately after wallet connect. * * Note: escrow categorization used to live here (`escrowAddresses: string[]`) - * but has moved to `/api/vault-categories`. The snapshot no longer carries + * but has moved to `/api/internal/vault-categories`. The snapshot no longer carries * that field; clients get the escrow set from the categorization endpoint. */ export interface ChainVaultsSnapshot { diff --git a/plugins/00.wagmi.ts b/plugins/00.wagmi.ts index a1464eb4..a39a3d58 100644 --- a/plugins/00.wagmi.ts +++ b/plugins/00.wagmi.ts @@ -56,7 +56,7 @@ export default defineNuxtPlugin((nuxtApp) => { const publicHttp = network.rpcUrls?.default?.http ?? [] transports[chainId] = fallback( [ - http(`/api/rpc/${chainId}`, batchConfig), + http(`/api/internal/rpc/${chainId}`, batchConfig), // Public fallback gets the same batch config so a proxy outage doesn't // explode into 50-100× more individual requests to the public endpoint. ...publicHttp.map(url => http(url, batchConfig)), diff --git a/sentry.client.config.ts b/sentry.client.config.ts index 514c59a2..05d5814f 100644 --- a/sentry.client.config.ts +++ b/sentry.client.config.ts @@ -9,7 +9,7 @@ if (sentryDsn) { Sentry.init({ dsn: sentryDsn, environment: appUrl?.includes('stage') ? 'staging' : 'production', - tunnel: '/api/sentry-tunnel', + tunnel: '/api/internal/sentry-tunnel', tracesSampleRate: 0.2, replaysOnErrorSampleRate: 1.0, replaysSessionSampleRate: 0.05, diff --git a/server/api/euler-chains.get.ts b/server/api/internal/euler-chains.get.ts similarity index 100% rename from server/api/euler-chains.get.ts rename to server/api/internal/euler-chains.get.ts diff --git a/server/api/intrinsic-apy.get.ts b/server/api/internal/intrinsic-apy.get.ts similarity index 100% rename from server/api/intrinsic-apy.get.ts rename to server/api/internal/intrinsic-apy.get.ts diff --git a/server/api/labels/[file].get.ts b/server/api/internal/labels/[file].get.ts similarity index 100% rename from server/api/labels/[file].get.ts rename to server/api/internal/labels/[file].get.ts diff --git a/server/api/oracle-adapter.get.ts b/server/api/internal/oracle-adapter.get.ts similarity index 100% rename from server/api/oracle-adapter.get.ts rename to server/api/internal/oracle-adapter.get.ts diff --git a/server/api/oracle-adapters.get.ts b/server/api/internal/oracle-adapters.get.ts similarity index 100% rename from server/api/oracle-adapters.get.ts rename to server/api/internal/oracle-adapters.get.ts diff --git a/server/api/pyth/updates.get.ts b/server/api/internal/pyth/updates.get.ts similarity index 100% rename from server/api/pyth/updates.get.ts rename to server/api/internal/pyth/updates.get.ts diff --git a/server/api/rewards/brevis.get.ts b/server/api/internal/rewards/brevis.get.ts similarity index 100% rename from server/api/rewards/brevis.get.ts rename to server/api/internal/rewards/brevis.get.ts diff --git a/server/api/rewards/fuul.get.ts b/server/api/internal/rewards/fuul.get.ts similarity index 100% rename from server/api/rewards/fuul.get.ts rename to server/api/internal/rewards/fuul.get.ts diff --git a/server/api/rewards/merkl.get.ts b/server/api/internal/rewards/merkl.get.ts similarity index 100% rename from server/api/rewards/merkl.get.ts rename to server/api/internal/rewards/merkl.get.ts diff --git a/server/api/rpc/[chainId].ts b/server/api/internal/rpc/[chainId].ts similarity index 100% rename from server/api/rpc/[chainId].ts rename to server/api/internal/rpc/[chainId].ts diff --git a/server/api/screen-address.post.ts b/server/api/internal/screen-address.post.ts similarity index 100% rename from server/api/screen-address.post.ts rename to server/api/internal/screen-address.post.ts diff --git a/server/api/sentry-tunnel.post.ts b/server/api/internal/sentry-tunnel.post.ts similarity index 100% rename from server/api/sentry-tunnel.post.ts rename to server/api/internal/sentry-tunnel.post.ts diff --git a/server/api/tenderly/simulate.post.ts b/server/api/internal/tenderly/simulate.post.ts similarity index 100% rename from server/api/tenderly/simulate.post.ts rename to server/api/internal/tenderly/simulate.post.ts diff --git a/server/api/tenderly/status.get.ts b/server/api/internal/tenderly/status.get.ts similarity index 100% rename from server/api/tenderly/status.get.ts rename to server/api/internal/tenderly/status.get.ts diff --git a/server/api/token-list.get.ts b/server/api/internal/token-list.get.ts similarity index 100% rename from server/api/token-list.get.ts rename to server/api/internal/token-list.get.ts diff --git a/server/api/tos.get.ts b/server/api/internal/tos.get.ts similarity index 100% rename from server/api/tos.get.ts rename to server/api/internal/tos.get.ts diff --git a/server/api/vault-categories.get.ts b/server/api/internal/vault-categories.get.ts similarity index 100% rename from server/api/vault-categories.get.ts rename to server/api/internal/vault-categories.get.ts diff --git a/server/api/vaults.get.ts b/server/api/internal/vaults.get.ts similarity index 100% rename from server/api/vaults.get.ts rename to server/api/internal/vaults.get.ts diff --git a/server/middleware/body-limit.ts b/server/middleware/body-limit.ts index d0233554..766b90a6 100644 --- a/server/middleware/body-limit.ts +++ b/server/middleware/body-limit.ts @@ -7,9 +7,9 @@ const SENTRY_LIMIT = 5 * 1024 * 1024 const DEFAULT_LIMIT = 1 * 1024 * 1024 function getLimit(pathname: string): number { - if (pathname.startsWith('/api/tenderly/')) return TENDERLY_LIMIT - if (pathname.startsWith('/api/rpc/')) return RPC_LIMIT - if (pathname === '/api/sentry-tunnel') return SENTRY_LIMIT + if (pathname.startsWith('/api/internal/tenderly/')) return TENDERLY_LIMIT + if (pathname.startsWith('/api/internal/rpc/')) return RPC_LIMIT + if (pathname === '/api/internal/sentry-tunnel') return SENTRY_LIMIT return DEFAULT_LIMIT } diff --git a/server/middleware/cors.ts b/server/middleware/cors.ts index f8b28e15..3fc83abf 100644 --- a/server/middleware/cors.ts +++ b/server/middleware/cors.ts @@ -1,5 +1,6 @@ import { createError, getRequestURL, setResponseHeader, sendNoContent } from 'h3' import { logger } from '~/server/utils/logger' +import { isInternalRequest } from '~/server/utils/internal-headers' function parseAllowedOrigins(): Set { // CORS_ALLOWED_ORIGINS is the dedicated CORS var (comma-separated). @@ -92,6 +93,11 @@ export default defineEventHandler((event) => { return } + // Everything else under /api/* is implementation detail. Flag it on the + // response so anyone watching the network tab (their own DevTools, their + // logging pipeline) sees the warning even if they ignore the URL prefix. + setResponseHeader(event, 'X-API-Stability', 'internal; may-break-without-notice') + const origin = event.node.req.headers.origin if (origin && allowedOrigins.has(origin)) { @@ -103,6 +109,20 @@ export default defineEventHandler((event) => { } throw createError({ statusCode: 403, statusMessage: 'Origin not allowed' }) } + else if (!origin && !isInternalRequest(event) && process.env.DOPPLER_ENVIRONMENT !== 'dev') { + // No Origin header in prod means a non-browser caller (curl, server-side + // fetch, etc.) hitting an internal endpoint. Same-app server-to-server + // traffic bypasses via the loopback `cf-connecting-ip` sentinel set by + // INTERNAL_FETCH_HEADERS — anyone else gets the door closed politely. + logger.warn({ ctx: 'cors', path: url.pathname }, 'rejected internal endpoint call without Origin') + throw createError({ + statusCode: 403, + statusMessage: 'Forbidden', + data: { + message: '/api/internal/* is not a public contract. See docs/PUBLIC_API.md.', + }, + }) + } setResponseHeader(event, 'Access-Control-Allow-Methods', 'POST, OPTIONS') setResponseHeader(event, 'Access-Control-Allow-Headers', 'Content-Type') diff --git a/server/plugins/warm-cache.ts b/server/plugins/warm-cache.ts index bcf13f61..82cad54c 100644 --- a/server/plugins/warm-cache.ts +++ b/server/plugins/warm-cache.ts @@ -42,9 +42,9 @@ * (one of its sources). Merkl's ERC20LOGPROCESSOR refresh also calls * `getVaultCategories(chainId)` to filter by the chain earn set. */ -import { LABEL_FILES, refreshLabelFile } from '../api/labels/[file].get' -import { refreshEulerChains } from '../api/euler-chains.get' -import { refreshTokenList } from '../api/token-list.get' +import { LABEL_FILES, refreshLabelFile } from '../api/internal/labels/[file].get' +import { refreshEulerChains } from '../api/internal/euler-chains.get' +import { refreshTokenList } from '../api/internal/token-list.get' import { getEnabledChainIds } from '~/utils/chain-env' import { parseDeprecatedChains } from '~/utils/parseDeprecatedChains' import { reportStatus } from '../utils/log' diff --git a/server/utils/escrow-perspective.ts b/server/utils/escrow-perspective.ts index 9ef8d42e..aff27f0d 100644 --- a/server/utils/escrow-perspective.ts +++ b/server/utils/escrow-perspective.ts @@ -21,7 +21,7 @@ interface EulerChainConfig { } async function fetchEulerChains(): Promise { - const data = await $fetch('/api/euler-chains', { headers: INTERNAL_FETCH_HEADERS }) + const data = await $fetch('/api/internal/euler-chains', { headers: INTERNAL_FETCH_HEADERS }) return Array.isArray(data) ? data as EulerChainConfig[] : [] } diff --git a/server/utils/labels-helpers.ts b/server/utils/labels-helpers.ts index e5d71457..5cbd4ab3 100644 --- a/server/utils/labels-helpers.ts +++ b/server/utils/labels-helpers.ts @@ -44,7 +44,7 @@ export async function fetchLabels( chainId: number, file: 'products.json' | 'entities.json' | 'earn-vaults.json', ): Promise { - return await $fetch(`/api/labels/${file}`, { + return await $fetch(`/api/internal/labels/${file}`, { query: { chainId }, headers: INTERNAL_FETCH_HEADERS, }) diff --git a/server/utils/labels-view.ts b/server/utils/labels-view.ts index cce12b94..e0eb3b1d 100644 --- a/server/utils/labels-view.ts +++ b/server/utils/labels-view.ts @@ -122,7 +122,7 @@ function isHttpUrl(value: string): boolean { } async function fetchTokenList(chainId: number): Promise { - const data = await $fetch('/api/token-list', { + const data = await $fetch('/api/internal/token-list', { query: { chainId }, headers: INTERNAL_FETCH_HEADERS, }) diff --git a/server/utils/vault-categories-store.ts b/server/utils/vault-categories-store.ts index a0b942c9..d9eb5c1a 100644 --- a/server/utils/vault-categories-store.ts +++ b/server/utils/vault-categories-store.ts @@ -115,7 +115,7 @@ interface EulerChainsResponse { const getChainFactoryAddresses = async (chainId: number): Promise => { try { - const chains = await $fetch('/api/euler-chains', { headers: INTERNAL_FETCH_HEADERS }) + const chains = await $fetch('/api/internal/euler-chains', { headers: INTERNAL_FETCH_HEADERS }) const entry = chains.find(c => c.chainId === chainId) const core = entry?.addresses?.coreAddrs const periphery = entry?.addresses?.peripheryAddrs diff --git a/server/utils/vaults-cache.ts b/server/utils/vaults-cache.ts index d8a93259..4b25f840 100644 --- a/server/utils/vaults-cache.ts +++ b/server/utils/vaults-cache.ts @@ -54,7 +54,7 @@ interface EarnVaultEntry { } const getChainConfig = async (chainId: number): Promise => { - const chains = await $fetch('/api/euler-chains', { headers: INTERNAL_FETCH_HEADERS }) + const chains = await $fetch('/api/internal/euler-chains', { headers: INTERNAL_FETCH_HEADERS }) return chains.find(c => c.chainId === chainId) } @@ -73,11 +73,11 @@ const getChainConfig = async (chainId: number): Promise { const [products, earn] = await Promise.all([ - $fetch>('/api/labels/products.json', { + $fetch>('/api/internal/labels/products.json', { query: { chainId }, headers: INTERNAL_FETCH_HEADERS, }).catch(() => ({} as Record)), - $fetch>('/api/labels/earn-vaults.json', { + $fetch>('/api/internal/labels/earn-vaults.json', { query: { chainId }, headers: INTERNAL_FETCH_HEADERS, }).catch(() => [] as Array), diff --git a/services/trm.ts b/services/trm.ts index 25fd8709..760fe020 100644 --- a/services/trm.ts +++ b/services/trm.ts @@ -10,7 +10,7 @@ export async function screenAddress( const timeout = setTimeout(() => controller.abort(), WALLET_SCREENING_TIMEOUT_MS) try { - const resp = await fetch('/api/screen-address', { + const resp = await fetch('/api/internal/screen-address', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address, vpnIsUsed }), diff --git a/tests/server/internal-request.test.ts b/tests/server/internal-request.test.ts index 321895fd..1995408d 100644 --- a/tests/server/internal-request.test.ts +++ b/tests/server/internal-request.test.ts @@ -11,7 +11,7 @@ * If this contract breaks — the sentinel stops being set, the helper * stops recognising it, or a middleware forgets to consult the helper — * every internal API→API call 502s/451s in prod. One such regression - * already shipped once (internal `/api/vaults` → `/api/euler-chains` + * already shipped once (internal `/api/internal/vaults` → `/api/internal/euler-chains` * 451'd by geo-gate). Lock it down. */ import { describe, it, expect } from 'vitest' diff --git a/tests/server/labels-validate-node.test.ts b/tests/server/labels-validate-node.test.ts index 8157cbd1..88c683ec 100644 --- a/tests/server/labels-validate-node.test.ts +++ b/tests/server/labels-validate-node.test.ts @@ -12,7 +12,7 @@ * corresponding defense (see git blame on server/api/labels/[file].get.ts). */ import { describe, it, expect } from 'vitest' -import { validateNode } from '~/server/api/labels/[file].get' +import { validateNode } from '~/server/api/internal/labels/[file].get' describe('validateNode — size caps', () => { it('rejects strings longer than 16 KiB (client-side DoS guard)', () => { diff --git a/tests/server/rewards-handlers.test.ts b/tests/server/rewards-handlers.test.ts index dd08655b..bd3d6668 100644 --- a/tests/server/rewards-handlers.test.ts +++ b/tests/server/rewards-handlers.test.ts @@ -43,7 +43,7 @@ const jsonResponse = (body: unknown, status = 200): Response => ({ const makeEvent = (query: Record): { event: H3Event, headers: Record } => { const headers: Record = {} const qs = new URLSearchParams(query).toString() - const path = `/api/rewards/x?${qs}` + const path = `/api/internal/rewards/x?${qs}` const event = { // h3 v1 reads getQuery() from `event.path` path, @@ -68,13 +68,13 @@ afterEach(() => { describe('GET /api/rewards/merkl', () => { it('rejects invalid chainId with 400', async () => { - const handler = (await import('~/server/api/rewards/merkl.get')).default + const handler = (await import('~/server/api/internal/rewards/merkl.get')).default const { event } = makeEvent({ chainId: 'not-a-number' }) await expect(handler(event)).rejects.toMatchObject({ statusCode: 400 }) }) it('rejects chainId not in enabled list', async () => { - const handler = (await import('~/server/api/rewards/merkl.get')).default + const handler = (await import('~/server/api/internal/rewards/merkl.get')).default const { event } = makeEvent({ chainId: String(DISABLED_CHAIN_ID) }) await expect(handler(event)).rejects.toMatchObject({ statusCode: 400 }) }) @@ -84,7 +84,7 @@ describe('GET /api/rewards/merkl', () => { // NOT expected here — it's served via /api/token-list now. globalThis.fetch = vi.fn(async () => jsonResponse([])) as unknown as typeof globalThis.fetch - const handler = (await import('~/server/api/rewards/merkl.get')).default + const handler = (await import('~/server/api/internal/rewards/merkl.get')).default const { event, headers } = makeEvent({ chainId: String(ENABLED_CHAIN_ID) }) const res = await handler(event) as { opportunities: { @@ -108,14 +108,14 @@ describe('GET /api/rewards/merkl', () => { describe('GET /api/rewards/brevis', () => { it('rejects invalid chainId with 400', async () => { - const handler = (await import('~/server/api/rewards/brevis.get')).default + const handler = (await import('~/server/api/internal/rewards/brevis.get')).default const { event } = makeEvent({ chainId: '0' }) await expect(handler(event)).rejects.toMatchObject({ statusCode: 400 }) }) it('returns raw body and sets Cache-Control on success', async () => { globalThis.fetch = vi.fn(async () => jsonResponse({ campaigns: [{ id: 'c1' }] })) as unknown as typeof globalThis.fetch - const handler = (await import('~/server/api/rewards/brevis.get')).default + const handler = (await import('~/server/api/internal/rewards/brevis.get')).default const { event, headers } = makeEvent({ chainId: String(ENABLED_CHAIN_ID) }) const res = await handler(event) as { campaigns: Array<{ id: string }> } expect(res.campaigns).toEqual([{ id: 'c1' }]) @@ -124,7 +124,7 @@ describe('GET /api/rewards/brevis', () => { it('returns 502 on cold-path upstream failure', async () => { globalThis.fetch = vi.fn(async () => jsonResponse(null, 500)) as unknown as typeof globalThis.fetch - const handler = (await import('~/server/api/rewards/brevis.get')).default + const handler = (await import('~/server/api/internal/rewards/brevis.get')).default // Fresh chainId so no stale cache entry rescues us via SWR. const { event } = makeEvent({ chainId: String(COLD_PATH_CHAIN_ID) }) await expect(handler(event)).rejects.toMatchObject({ statusCode: 502 }) @@ -133,7 +133,7 @@ describe('GET /api/rewards/brevis', () => { describe('GET /api/rewards/fuul', () => { it('rejects invalid chainId with 400', async () => { - const handler = (await import('~/server/api/rewards/fuul.get')).default + const handler = (await import('~/server/api/internal/rewards/fuul.get')).default const { event } = makeEvent({ chainId: '-1' }) await expect(handler(event)).rejects.toMatchObject({ statusCode: 400 }) }) @@ -144,7 +144,7 @@ describe('GET /api/rewards/fuul', () => { return jsonResponse([{ proto: 'e' }]) }) as unknown as typeof globalThis.fetch - const handler = (await import('~/server/api/rewards/fuul.get')).default + const handler = (await import('~/server/api/internal/rewards/fuul.get')).default const { event, headers } = makeEvent({ chainId: String(ENABLED_CHAIN_ID) }) const res = await handler(event) as { euler: unknown, looping: unknown } expect(res.euler).toEqual([{ proto: 'e' }]) diff --git a/tests/server/screen-address.test.ts b/tests/server/screen-address.test.ts index 37c23640..a5057d67 100644 --- a/tests/server/screen-address.test.ts +++ b/tests/server/screen-address.test.ts @@ -20,7 +20,7 @@ vi.mock('~/server/utils/logger', () => ({ }, })) -const handler = (await import('~/server/api/screen-address.post')).default +const handler = (await import('~/server/api/internal/screen-address.post')).default const USER = '0x0000000000000000000000000000000000000001' const SCREENING_URI = 'https://trm.example/screen' @@ -40,7 +40,7 @@ function makeEvent(body: unknown, headers: Record { +describe('POST /api/internal/screen-address', () => { afterEach(() => { delete process.env.WALLET_SCREENING_URI vi.unstubAllGlobals() diff --git a/tests/server/sentry-tunnel.test.ts b/tests/server/sentry-tunnel.test.ts index 59837e88..61c674c6 100644 --- a/tests/server/sentry-tunnel.test.ts +++ b/tests/server/sentry-tunnel.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseAndValidateSentryEnvelope } from '~/server/api/sentry-tunnel.post' +import { parseAndValidateSentryEnvelope } from '~/server/api/internal/sentry-tunnel.post' const DSN = 'https://public@example.ingest.sentry.io/123' diff --git a/utils/public-client.ts b/utils/public-client.ts index 2586fa54..84479cd6 100644 --- a/utils/public-client.ts +++ b/utils/public-client.ts @@ -21,6 +21,14 @@ export const isPublicAnkrRpcUrl = (rpcUrl: string): boolean => { } } +// Server-side calls to `/api/internal/rpc/*` originate from inside the Nuxt +// process and never carry an `Origin` header, so the CORS middleware would +// otherwise 403 them in prod. The loopback `cf-connecting-ip` sentinel +// declared in `server/utils/internal-headers.ts` is what `isInternalRequest` +// looks for; we inline it here to avoid pulling server-only code into the +// client bundle. Keep this in lockstep with `INTERNAL_FETCH_HEADERS`. +const SERVER_INTERNAL_RPC_HEADERS = { 'cf-connecting-ip': '127.0.0.1' } + export const getPublicClient = (rpcUrl: string): PublicClient => { const cached = clientCache.get(rpcUrl) if (cached) { @@ -36,6 +44,9 @@ export const getPublicClient = (rpcUrl: string): PublicClient => { batchSize: isPublicAnkrRpcUrl(rpcUrl) ? ANKR_PUBLIC_BATCH_SIZE : DEFAULT_BATCH_SIZE, wait: 100, }, + fetchOptions: import.meta.server + ? { headers: SERVER_INTERNAL_RPC_HEADERS } + : undefined, }), }) diff --git a/utils/pyth.ts b/utils/pyth.ts index be5100a0..d5ce0f79 100644 --- a/utils/pyth.ts +++ b/utils/pyth.ts @@ -116,7 +116,7 @@ const fetchPythUpdateDataDirect = async (feedIds: Hex[], _endpoint: string): Pro } try { - const url = new URL('/api/pyth/updates', window.location.origin) + const url = new URL('/api/internal/pyth/updates', window.location.origin) feedIds.forEach(id => url.searchParams.append('ids[]', id)) url.searchParams.set('encoding', 'hex') @@ -356,7 +356,7 @@ export const fetchPythPrices = async ( } try { - const url = new URL('/api/pyth/updates', window.location.origin) + const url = new URL('/api/internal/pyth/updates', window.location.origin) missing.forEach(id => url.searchParams.append('ids[]', id)) url.searchParams.set('encoding', 'hex') url.searchParams.set('parsed', 'true')