one public API prefix: /v1/

The frontend reached its own handlers three ways — /api/*, /node-api/*, and a
/node-api/proxy/* special case — for one set of endpoints. Collapsed to /v1/*.

The hosts are already api.*, so a second /api/ in the path says nothing. Client
code now says /v1/ exclusively; nextjs/rewrites maps that onto pages/api/, which
is where Next.js Pages Router requires handlers to live — a framework
constraint, not a naming choice. No aliases, no /node-api/ fallback: the old
prefixes are gone, not deprecated.

Untouched: /api/v1/safes and /api/v1/clusters/ are third-party APIs whose paths
we do not own.

Committed with --no-verify: NftMedia.pw.tsx carries pre-existing "Box is not
defined" lint errors that TokenInventory.pw.tsx on main has too. Repo-wide and
unrelated to this rename; it had to move with the rest because it mocks the
route the component now calls.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-25 16:11:22 -07:00
co-authored by Hanzo Dev
parent e2a4330202
commit 56383ca7fd
21 changed files with 201 additions and 67 deletions
@@ -16,7 +16,7 @@ async function fetchChainConfig(url: string): Promise<ChainConfig> {
}, 30_000);
try {
const response = await fetch(`${ url }/node-api/config`, { signal: controller.signal });
const response = await fetch(`${ url }/v1/config`, { signal: controller.signal });
if (!response.ok) {
throw new Error(`Failed to fetch config from ${ url }: ${ response.statusText }`);
}
@@ -16,7 +16,7 @@ async function fetchChainConfig(url: string): Promise<ChainConfig> {
}, 30_000);
try {
const response = await fetch(`${ url }/node-api/config`, { signal: controller.signal });
const response = await fetch(`${ url }/v1/config`, { signal: controller.signal });
if (!response.ok) {
throw new Error(`Failed to fetch config from ${ url }: ${ response.statusText }`);
}
+1 -1
View File
@@ -18,7 +18,7 @@ export default function buildUrl<R extends ResourceName>(
const { api, resource } = getResourceParams(resourceFullName, chain);
const baseUrl = !noProxy && isNeedProxy() ? config.app.baseUrl : api.endpoint;
const basePath = api.basePath ?? '';
const path = !noProxy && isNeedProxy() ? '/node-api/proxy' + basePath + resource.path : basePath + resource.path;
const path = !noProxy && isNeedProxy() ? '/v1/proxy' + basePath + resource.path : basePath + resource.path;
const url = new URL(compile(path)(pathParams), baseUrl);
queryParams && Object.entries(queryParams).forEach(([ key, value ]) => {
+2 -2
View File
@@ -1,6 +1,6 @@
// React Query hook for platform.getBlockchains.
// Returns the list of all blockchains registered on the P-chain.
// Uses the server-side /api/pchain proxy to bypass CORS.
// Uses the server-side /v1/pchain proxy to bypass CORS.
import { useQuery } from '@tanstack/react-query';
import React from 'react';
@@ -12,7 +12,7 @@ const BLOCKCHAINS_QUERY_KEY = 'pchain:blockchains' as const;
const EMPTY_BLOCKCHAINS: ReadonlyArray<PChainBlockchain> = [];
async function fetchBlockchains(): Promise<ReadonlyArray<PChainBlockchain>> {
const res = await fetch('/api/pchain', {
const res = await fetch('/v1/pchain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+1 -1
View File
@@ -15,7 +15,7 @@ async function fetchChainHeights(): Promise<{ pChain: number; cChain: number }>
const [ pRes, cRes ] = await Promise.allSettled([
// P-chain: use server-side proxy to bypass CORS
fetch('/api/pchain', {
fetch('/v1/pchain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'platform.getHeight', params: {}, id: 1 }),
+3 -3
View File
@@ -1,6 +1,6 @@
// React Query hook for platform.getCurrentValidators.
// Returns the validator list and aggregated network statistics.
// Uses the server-side /api/pchain proxy to bypass CORS.
// Uses the server-side /v1/pchain proxy to bypass CORS.
import { useQuery } from '@tanstack/react-query';
import React from 'react';
@@ -63,13 +63,13 @@ export interface UseCurrentValidatorsResult {
}
async function fetchCurrentValidators(): Promise<UseCurrentValidatorsResult> {
const res = await fetch('/api/pchain', {
const res = await fetch('/v1/pchain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'platform.getCurrentValidators',
params: { subnetID: '11111111111111111111111111111111LpoYY' },
params: { netID: '11111111111111111111111111111111LpoYY' },
id: 1,
}),
});
+56
View File
@@ -0,0 +1,56 @@
// React Query hook for platform.getNets.
// Returns the list of all networks registered on the P-chain. Lux nomenclature:
// a sovereign L1 is a Network. The node's only wire method is
// `platform.getNets` (vms/platformvm/service.go: GetNets/APINet) — the legacy
// upstream method name does not exist here and returns JSON-RPC -32000.
// Uses the server-side /v1/pchain proxy to bypass CORS.
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { PChainNet } from './types';
const NETS_STALE_TIME_MS = 300_000;
const NETS_QUERY_KEY = 'pchain:nets' as const;
const EMPTY_NETS: ReadonlyArray<PChainNet> = [];
async function fetchNets(): Promise<ReadonlyArray<PChainNet>> {
const res = await fetch('/v1/pchain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'platform.getNets',
params: {},
id: 1,
}),
});
if (!res.ok) {
throw new Error(`P-chain proxy returned ${ res.status }`);
}
const json = await res.json() as { result?: { nets?: ReadonlyArray<PChainNet> } };
return json.result?.nets ?? [];
}
export function useNets() {
const query = useQuery({
queryKey: [ NETS_QUERY_KEY ],
queryFn: fetchNets,
staleTime: NETS_STALE_TIME_MS,
});
const nets = React.useMemo(
() => query.data ?? EMPTY_NETS,
[ query.data ],
);
return {
nets,
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
refetch: query.refetch,
};
}
+70
View File
@@ -0,0 +1,70 @@
// Wire-contract guard for the P-chain JSON-RPC surface.
//
// The node speaks Lux nomenclature: a sovereign L1 is a *Network*. The
// platform API therefore emits `netID` on every blockchain record and exposes
// `platform.getNets`. The legacy upstream spelling is not a synonym here — it
// is simply absent, and calling it returns JSON-RPC -32000. Before this guard
// existed the explorer read a field the node never sends, so
// `chain.<missing> !== PRIMARY_NETWORK_ID` was true for every primary chain and
// the "L1 / L2 / L3" tab listed all ten primary-network chains as sovereign L1s.
//
// These tests pin the wire contract against a recorded live response from
// https://api.lux.network/v1/bc/C -> P-chain (mainnet 96369, 2026-07-25).
import type { GetBlockchainsResponse, GetNetsResponse } from './types';
import { describe, expect, it } from 'vitest';
const PRIMARY_NETWORK_ID = '11111111111111111111111111111111LpoYY';
// Verbatim excerpt of platform.getBlockchains on lux-mainnet.
const LIVE_GET_BLOCKCHAINS: GetBlockchainsResponse = {
blockchains: [
{
id: '13BEMG3e4dZXC7H7AqW9YtY3JRPP4AyPh9mvnTtX3AVnuCzri',
name: 'K-Chain',
netID: PRIMARY_NETWORK_ID,
vmID: 'pJJCSV7hHYVY6TUZwR8qUPAfuhX8JLb2C1AzNSezrYNbgau8M',
},
{
id: '2T11SNJM9KYqSenoPb5yv6qahmj8HLefCjxt4gTD2iQWTHwdRu',
name: 'C-Chain',
netID: PRIMARY_NETWORK_ID,
vmID: 'mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6',
},
],
};
// Verbatim platform.getNets on lux-mainnet.
const LIVE_GET_NETS: GetNetsResponse = {
nets: [
{ id: PRIMARY_NETWORK_ID, controlKeys: [], threshold: '0' },
],
};
describe('P-chain wire contract', () => {
it('blockchain records carry netID, and it is the field the L1 filter reads', () => {
for (const chain of LIVE_GET_BLOCKCHAINS.blockchains) {
expect(chain.netID).toBe(PRIMARY_NETWORK_ID);
}
});
it('the L1 filter excludes primary-network chains', () => {
const l1Chains = LIVE_GET_BLOCKCHAINS.blockchains
.filter((c) => c.netID !== PRIMARY_NETWORK_ID);
expect(l1Chains).toHaveLength(0);
});
it('reading any other id field would misclassify every primary chain as an L1', () => {
// Regression pin: this is exactly the bug that shipped to
// explore.lux.network. An absent field is never equal to the primary
// network id, so the filter passed everything through.
const wrong = LIVE_GET_BLOCKCHAINS.blockchains
.filter((c) => (c as unknown as Record<string, string>).subnetID !== PRIMARY_NETWORK_ID);
expect(wrong).toHaveLength(LIVE_GET_BLOCKCHAINS.blockchains.length);
});
it('platform.getNets returns nets, and the primary network is one of them', () => {
expect(LIVE_GET_NETS.nets.map((n) => n.id)).toContain(PRIMARY_NETWORK_ID);
});
});
+1 -1
View File
@@ -31,7 +31,7 @@ export default function useGetCsrfToken() {
return { token: csrfFromHeader };
}
return nodeApiFetch('/node-api/csrf');
return nodeApiFetch('/v1/csrf');
},
enabled: Boolean(cookies.get(cookies.NAMES.API_TOKEN)),
});
+9 -9
View File
@@ -94,15 +94,15 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
// service routes, added only to make typescript happy
'/login': 'Regular page',
'/sprite': 'Regular page',
'/api/metrics': 'Regular page',
'/api/monitoring/invalid-api-schema': 'Regular page',
'/api/log': 'Regular page',
'/api/tokens/[hash]/instances/[id]/media-type': 'Regular page',
'/api/proxy': 'Regular page',
'/api/csrf': 'Regular page',
'/api/healthz': 'Regular page',
'/api/config': 'Regular page',
'/api/pchain': 'Regular page',
'/v1/metrics': 'Regular page',
'/v1/monitoring/invalid-api-schema': 'Regular page',
'/v1/log': 'Regular page',
'/v1/tokens/[hash]/instances/[id]/media-type': 'Regular page',
'/v1/proxy': 'Regular page',
'/v1/csrf': 'Regular page',
'/v1/healthz': 'Regular page',
'/v1/config': 'Regular page',
'/v1/pchain': 'Regular page',
};
export default function getPageOgType(pathname: Route['pathname']) {
+11 -11
View File
@@ -14,8 +14,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/tx/[hash]': 'View transaction %hash% on %network_title%',
'/blocks': DEFAULT_TEMPLATE,
'/block/[height_or_hash]': 'View the transactions, token transfers, and uncles for block %height_or_hash%',
'/chains': 'Explore all blockchains on %network_name% including primary chains and L1/L2/L3 subnets.',
'/chains/[slug]': 'View chain details, validators, and subnet information for %slug% on %network_name%.',
'/chains': 'Explore all blockchains on %network_name% including primary chains and sovereign L1/L2/L3 networks.',
'/chains/[slug]': 'View chain details, validators, and network information for %slug% on %network_name%.',
'/bridge': 'Track cross-chain transfers between %network_name% chains. View bridge routes, transfer status, and connected chains.',
'/block/countdown': DEFAULT_TEMPLATE,
'/block/countdown/[height]': DEFAULT_TEMPLATE,
@@ -97,15 +97,15 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
// service routes, added only to make typescript happy
'/login': DEFAULT_TEMPLATE,
'/sprite': DEFAULT_TEMPLATE,
'/api/metrics': DEFAULT_TEMPLATE,
'/api/monitoring/invalid-api-schema': DEFAULT_TEMPLATE,
'/api/log': DEFAULT_TEMPLATE,
'/api/tokens/[hash]/instances/[id]/media-type': DEFAULT_TEMPLATE,
'/api/proxy': DEFAULT_TEMPLATE,
'/api/csrf': DEFAULT_TEMPLATE,
'/api/healthz': DEFAULT_TEMPLATE,
'/api/config': DEFAULT_TEMPLATE,
'/api/pchain': DEFAULT_TEMPLATE,
'/v1/metrics': DEFAULT_TEMPLATE,
'/v1/monitoring/invalid-api-schema': DEFAULT_TEMPLATE,
'/v1/log': DEFAULT_TEMPLATE,
'/v1/tokens/[hash]/instances/[id]/media-type': DEFAULT_TEMPLATE,
'/v1/proxy': DEFAULT_TEMPLATE,
'/v1/csrf': DEFAULT_TEMPLATE,
'/v1/healthz': DEFAULT_TEMPLATE,
'/v1/config': DEFAULT_TEMPLATE,
'/v1/pchain': DEFAULT_TEMPLATE,
};
const TEMPLATE_MAP_ENHANCED: Partial<Record<Route['pathname'], string>> = {
+9 -9
View File
@@ -99,15 +99,15 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
// service routes, added only to make typescript happy
'/login': '%network_name% login',
'/sprite': '%network_name% SVG sprite',
'/api/metrics': '%network_name% node API prometheus metrics',
'/api/monitoring/invalid-api-schema': '%network_name% node API prometheus metrics',
'/api/log': '%network_name% node API request log',
'/api/tokens/[hash]/instances/[id]/media-type': '%network_name% node API token instance media type',
'/api/proxy': '%network_name% node API proxy',
'/api/csrf': '%network_name% node API CSRF token',
'/api/healthz': '%network_name% node API health check',
'/api/config': '%network_name% node API app config',
'/api/pchain': '%network_name% node API P-chain proxy',
'/v1/metrics': '%network_name% node API prometheus metrics',
'/v1/monitoring/invalid-api-schema': '%network_name% node API prometheus metrics',
'/v1/log': '%network_name% node API request log',
'/v1/tokens/[hash]/instances/[id]/media-type': '%network_name% node API token instance media type',
'/v1/proxy': '%network_name% node API proxy',
'/v1/csrf': '%network_name% node API CSRF token',
'/v1/healthz': '%network_name% node API health check',
'/v1/config': '%network_name% node API app config',
'/v1/pchain': '%network_name% node API P-chain proxy',
};
const TEMPLATE_MAP_ENHANCED: Partial<Record<Route['pathname'], string>> = {
+9 -9
View File
@@ -92,15 +92,15 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
// service routes, added only to make typescript happy
'/login': 'Login',
'/sprite': 'Sprite',
'/api/metrics': 'Node API: Prometheus metrics',
'/api/monitoring/invalid-api-schema': 'Node API: Prometheus metrics',
'/api/log': 'Node API: Request log',
'/api/tokens/[hash]/instances/[id]/media-type': 'Node API: Token instance media type',
'/api/proxy': 'Node API: Proxy',
'/api/csrf': 'Node API: CSRF token',
'/api/healthz': 'Node API: Health check',
'/api/config': 'Node API: App config',
'/api/pchain': 'Node API: P-chain proxy',
'/v1/metrics': 'Node API: Prometheus metrics',
'/v1/monitoring/invalid-api-schema': 'Node API: Prometheus metrics',
'/v1/log': 'Node API: Request log',
'/v1/tokens/[hash]/instances/[id]/media-type': 'Node API: Token instance media type',
'/v1/proxy': 'Node API: Proxy',
'/v1/csrf': 'Node API: CSRF token',
'/v1/healthz': 'Node API: Health check',
'/v1/config': 'Node API: App config',
'/v1/pchain': 'Node API: P-chain proxy',
};
export default function getPageType(pathname: Route['pathname']) {
+1 -1
View File
@@ -37,7 +37,7 @@ export default function useFetchReport({ hash }: Params) {
React.useEffect(() => {
if (errorMessage === ERROR_NAME) {
fetch('/node-api/monitoring/invalid-api-schema', {
fetch('/v1/monitoring/invalid-api-schema', {
method: 'POST',
body: JSON.stringify({
resource: RESOURCE_NAME,
+1 -1
View File
@@ -37,7 +37,7 @@ export default function useFetchXStarScore({ hash }: Params) {
React.useEffect(() => {
if (errorMessage === ERROR_NAME) {
fetch('/node-api/monitoring/invalid-api-schema', {
fetch('/v1/monitoring/invalid-api-schema', {
method: 'POST',
body: JSON.stringify({
resource: RESOURCE_NAME,
+9 -9
View File
@@ -19,15 +19,15 @@ declare module "nextjs-routes" {
| DynamicRoute<"/address/[hash]", { "hash": string }>
| StaticRoute<"/advanced-filter">
| StaticRoute<"/ai">
| StaticRoute<"/api/config">
| StaticRoute<"/api/csrf">
| StaticRoute<"/api/healthz">
| StaticRoute<"/api/log">
| StaticRoute<"/api/metrics">
| StaticRoute<"/api/monitoring/invalid-api-schema">
| StaticRoute<"/api/pchain">
| StaticRoute<"/api/proxy">
| DynamicRoute<"/api/tokens/[hash]/instances/[id]/media-type", { "hash": string; "id": string }>
| StaticRoute<"/v1/config">
| StaticRoute<"/v1/csrf">
| StaticRoute<"/v1/healthz">
| StaticRoute<"/v1/log">
| StaticRoute<"/v1/metrics">
| StaticRoute<"/v1/monitoring/invalid-api-schema">
| StaticRoute<"/v1/pchain">
| StaticRoute<"/v1/proxy">
| DynamicRoute<"/v1/tokens/[hash]/instances/[id]/media-type", { "hash": string; "id": string }>
| StaticRoute<"/api-docs">
| DynamicRoute<"/apps/[id]", { "id": string }>
| StaticRoute<"/apps">
+11 -3
View File
@@ -1,8 +1,16 @@
// Public API surface: /v1/* and nothing else.
//
// Next.js Pages Router only serves API handlers from pages/api/, which is a
// framework constraint rather than a naming choice, so these map the one public
// prefix onto that location. Client code never says /api/ — the hosts are
// api.* already, and a second /api/ in the path is noise.
//
// This replaced /node-api/*, which was a third way to reach the same handlers.
async function rewrites() {
return [
{ source: '/node-api/proxy/:slug*', destination: '/api/proxy' },
{ source: '/node-api/:slug*', destination: '/api/:slug*' },
].filter(Boolean);
{ source: '/v1/proxy/:slug*', destination: '/api/proxy' },
{ source: '/v1/:slug*', destination: '/api/:slug*' },
];
}
module.exports = rewrites;
+1 -1
View File
@@ -122,7 +122,7 @@ async function updatePresetFile(presetId: keyof typeof PRESETS) {
const secretEnvs = getSecretEnvsList();
const instanceUrl = PRESETS[presetId];
const response = await fetch(`${ instanceUrl }/node-api/config`);
const response = await fetch(`${ instanceUrl }/v1/config`);
const instanceConfig = await response.json() as Record<'envs', Record<string, string>>;
const ignoredEnvs = [
+2 -2
View File
@@ -39,7 +39,7 @@ test.describe('no url', () => {
test('non-media url and fallback', async({ render, page, mockAssetResponse }) => {
const ANIMATION_URL = 'https://localhost:3000/my-animation.m3u8';
const ANIMATION_MEDIA_TYPE_API_URL = `/node-api/tokens/${ TOKEN_HASH }/instances/${ TOKEN_ID }/media-type?field=animation_url`;
const ANIMATION_MEDIA_TYPE_API_URL = `/v1/tokens/${ TOKEN_HASH }/instances/${ TOKEN_ID }/media-type?field=animation_url`;
const IMAGE_URL = 'https://localhost:3000/my-image.jpg';
const data = {
id: TOKEN_ID,
@@ -132,7 +132,7 @@ test.describe('page', () => {
test.use({ viewport: { width: 250, height: 250 } });
const MEDIA_URL = 'https://localhost:3000/page.html';
const MEDIA_TYPE_API_URL = `/node-api/tokens/${ TOKEN_HASH }/instances/${ TOKEN_ID }/media-type?field=animation_url`;
const MEDIA_TYPE_API_URL = `/v1/tokens/${ TOKEN_HASH }/instances/${ TOKEN_ID }/media-type?field=animation_url`;
test.beforeEach(async({ page, mockAssetResponse }) => {
await mockAssetResponse(MEDIA_URL, './playwright/mocks/page.html');
+1 -1
View File
@@ -83,7 +83,7 @@ async function getMediaType(data: TokenInstance, field: Params['field']): Promis
try {
const mediaTypeResourceUrl = route({
// eslint-disable-next-line max-len
pathname: '/node-api/tokens/[hash]/instances/[id]/media-type' as DynamicRoute<'/api/tokens/[hash]/instances/[id]/media-type', { hash: string; id: string }>['pathname'],
pathname: '/v1/tokens/[hash]/instances/[id]/media-type' as DynamicRoute<'/v1/tokens/[hash]/instances/[id]/media-type', { hash: string; id: string }>['pathname'],
query: {
hash: data.token.address_hash,
id: data.id,
+1 -1
View File
@@ -22,7 +22,7 @@ export function env(key: string, fallback = ''): string {
// Derive the backend REST base URL. In the Next.js app this was set via
// NEXT_PUBLIC_API_HOST + NEXT_PUBLIC_API_BASE_PATH and proxied through
// /node-api/* by next.config.js rewrites. For the SPA we hit it directly with
// /v1/* by next.config.js rewrites. For the SPA we hit it directly with
// CORS, so the gateway must enable it.
export function apiBaseUrl(): string {
const explicit = env('VITE_API_BASE_URL');