From a6336d87049032d3fc8ef699495aa5e1785de875 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Tue, 25 Aug 2026 16:09:47 -0500 Subject: [PATCH] feat(internal-explorer): confirm before switching explorer hosts aws-dev cannot reach prod audit, so toggling Mainnet or Sepolia there must send the user to the prod Internal Explorer after they confirm. --- .env.example | 5 + .../internal-explorer/block/[hash]/route.ts | 5 +- app/api/internal-explorer/blocks/route.ts | 4 +- .../internal-explorer/bundle/[hash]/route.ts | 5 +- app/api/internal-explorer/chain.ts | 15 + app/api/internal-explorer/config.ts | 19 ++ app/api/internal-explorer/hosts.test.ts | 274 ++++++++++++++++++ app/api/internal-explorer/rejected/route.ts | 5 +- .../shadow-block/[hash]/route.ts | 4 +- .../shadow-candidates-batch/route.ts | 4 +- .../shadow-candidates/route.ts | 4 +- app/api/internal-explorer/txn/[hash]/route.ts | 4 +- app/api/internal-explorer/txs/route.ts | 4 +- app/internal-explorer/chains.ts | 13 +- .../components/ChainToggle.tsx | 88 +++++- .../components/HostSwitchModal.tsx | 76 +++++ app/internal-explorer/hosts.ts | 137 +++++++++ app/internal-explorer/layout.tsx | 20 +- .../library/ExplorerHostsProvider.tsx | 29 ++ .../library/useExplorerChain.ts | 6 +- 20 files changed, 685 insertions(+), 36 deletions(-) create mode 100644 app/api/internal-explorer/chain.ts create mode 100644 app/api/internal-explorer/hosts.test.ts create mode 100644 app/internal-explorer/components/HostSwitchModal.tsx create mode 100644 app/internal-explorer/hosts.ts create mode 100644 app/internal-explorer/library/ExplorerHostsProvider.tsx diff --git a/.env.example b/.env.example index 115497d..93c3751 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,11 @@ NEXT_PUBLIC_VIBENET_RPC_URL=https://rpc.vibes.base.org # when set, /api/internal-explorer prefers it over S3 and falls back to S3 when # unset/empty. # TIPS_MAINNET_AUDIT_RPC_URL= +# Public origin that serves a chain's Internal Explorer (runtime, not +# NEXT_PUBLIC_*). Unset locally so the chain toggle stays on this origin. +# BASE_UI_ZERONET_HOST=https://base-ui.aws-dev.cbhq.net +# BASE_UI_MAINNET_HOST=https://base-ui.aws.cbhq.net +# BASE_UI_SEPOLIA_HOST=https://base-ui.aws.cbhq.net # Per-chain block explorer for Internal Explorer links (client-visible). Defaults: # mainnet=https://base.blockscout.com, sepolia=https://base-sepolia.blockscout.com. # NEXT_PUBLIC_TIPS_ZERONET_EXPLORER_URL= diff --git a/app/api/internal-explorer/block/[hash]/route.ts b/app/api/internal-explorer/block/[hash]/route.ts index 100f082..b5bb3f7 100644 --- a/app/api/internal-explorer/block/[hash]/route.ts +++ b/app/api/internal-explorer/block/[hash]/route.ts @@ -1,7 +1,8 @@ import { type Hash } from 'viem'; -import { resolveExplorerChain, type ExplorerChain } from '../../../../internal-explorer/chains'; +import { type ExplorerChain } from '../../../../internal-explorer/chains'; import { calculateTransactionFee } from '../../../../internal-explorer/library/explorer-format'; +import { resolveExplorerChainFromRequest } from '../../chain'; import { bundleHistoryFromAuditEvents, getAuditEventsByBlockNumber, @@ -304,7 +305,7 @@ async function buildAndCacheBlockData( export async function GET(request: Request, { params }: { params: Promise<{ hash: string }> }) { const disabled = explorerDisabledResponse(); if (disabled) return disabled; - const chain = resolveExplorerChain(new URL(request.url).searchParams.get('chain')); + const chain = resolveExplorerChainFromRequest(request); const rpcUrl = getRpcUrl(chain); try { diff --git a/app/api/internal-explorer/blocks/route.ts b/app/api/internal-explorer/blocks/route.ts index 27080dd..e8488bf 100644 --- a/app/api/internal-explorer/blocks/route.ts +++ b/app/api/internal-explorer/blocks/route.ts @@ -1,4 +1,4 @@ -import { resolveExplorerChain } from '../../../internal-explorer/chains'; +import { resolveExplorerChainFromRequest } from '../chain'; import { BlockListUnavailableError, InvalidBlockListQueryError, @@ -17,7 +17,7 @@ export type { BlockSummary, BlocksPage, BlocksResponse } from '../block-list'; export async function GET(request: Request) { const disabled = explorerDisabledResponse(); if (disabled) return disabled; - const chain = resolveExplorerChain(new URL(request.url).searchParams.get('chain')); + const chain = resolveExplorerChainFromRequest(request); try { const query = parseBlockListQuery(new URL(request.url).searchParams); diff --git a/app/api/internal-explorer/bundle/[hash]/route.ts b/app/api/internal-explorer/bundle/[hash]/route.ts index c819656..8d1f789 100644 --- a/app/api/internal-explorer/bundle/[hash]/route.ts +++ b/app/api/internal-explorer/bundle/[hash]/route.ts @@ -1,6 +1,7 @@ import { type Hash } from 'viem'; -import { resolveExplorerChain, type ExplorerChain } from '../../../../internal-explorer/chains'; +import { type ExplorerChain } from '../../../../internal-explorer/chains'; +import { resolveExplorerChainFromRequest } from '../../chain'; import { bundleHistoryFromAuditEvents, getJoinedAuditEventsByBundle, @@ -95,7 +96,7 @@ async function enrichBundleTransactionsFromRpc( export async function GET(request: Request, { params }: { params: Promise<{ hash: string }> }) { const disabled = explorerDisabledResponse(); if (disabled) return disabled; - const chain = resolveExplorerChain(new URL(request.url).searchParams.get('chain')); + const chain = resolveExplorerChainFromRequest(request); try { const { hash } = await params; diff --git a/app/api/internal-explorer/chain.ts b/app/api/internal-explorer/chain.ts new file mode 100644 index 0000000..3ac7074 --- /dev/null +++ b/app/api/internal-explorer/chain.ts @@ -0,0 +1,15 @@ +// Resolve the active ExplorerChain from an API request: explicit `?chain=` +// wins; otherwise the Host header picks the origin default (zeronet on aws-dev, +// mainnet on aws prod or when no hosts are configured). +import { resolveExplorerChain, type ExplorerChain } from '../../internal-explorer/chains'; +import { originFromHostHeader } from '../../internal-explorer/hosts'; +import { getExplorerHosts } from './config'; + +export function resolveExplorerChainFromRequest(request: Request): ExplorerChain { + const chainParam = new URL(request.url).searchParams.get('chain'); + const origin = originFromHostHeader( + request.headers.get('host'), + request.headers.get('x-forwarded-host'), + ); + return resolveExplorerChain(chainParam, origin, getExplorerHosts()); +} diff --git a/app/api/internal-explorer/config.ts b/app/api/internal-explorer/config.ts index 7a8ecb6..7df99d9 100644 --- a/app/api/internal-explorer/config.ts +++ b/app/api/internal-explorer/config.ts @@ -4,6 +4,7 @@ import { S3Client, type S3ClientConfig } from '@aws-sdk/client-s3'; import type { ExplorerChain } from '../../internal-explorer/chains'; +import type { ExplorerHostMap } from '../../internal-explorer/hosts'; // Env var infix for each chain: TIPS_MAINNET_*, TIPS_SEPOLIA_*, TIPS_ZERONET_*. const ENV_PREFIX: Record = { @@ -90,3 +91,21 @@ export function getShadowMetricsUrl(chain: ExplorerChain): string | undefined { export function isAuditConfigured(chain: ExplorerChain): boolean { return Boolean(getAuditRpcUrl(chain)); } + +// Public origin that serves observability for a chain. Unset locally so the +// client stays on the current origin with no host-switch prompt. Set at runtime +// (not NEXT_PUBLIC_*) so the same image can default zeronet on aws-dev and +// mainnet on aws prod. Helm: BASE_UI__HOST. +export function getExplorerHost(chain: ExplorerChain): string | undefined { + const value = envValue([`BASE_UI_${ENV_PREFIX[chain]}_HOST`])?.trim(); + return value || undefined; +} + +export function getExplorerHosts(): ExplorerHostMap { + const hosts: ExplorerHostMap = {}; + for (const chain of ['mainnet', 'sepolia', 'zeronet'] as const) { + const host = getExplorerHost(chain); + if (host) hosts[chain] = host; + } + return hosts; +} diff --git a/app/api/internal-explorer/hosts.test.ts b/app/api/internal-explorer/hosts.test.ts new file mode 100644 index 0000000..d3ad8a4 --- /dev/null +++ b/app/api/internal-explorer/hosts.test.ts @@ -0,0 +1,274 @@ +import assert from 'node:assert/strict'; + +import { afterEach, beforeEach, describe, test } from 'vitest'; + +import { resolveExplorerChainFromRequest } from './chain'; +import { getExplorerHost, getExplorerHosts } from './config'; +import { DEFAULT_EXPLORER_CHAIN, resolveExplorerChain, type ExplorerChain } from '../../internal-explorer/chains'; +import { + configuredHostOrigin, + defaultExplorerChainForOrigin, + explorerHostEnvironment, + explorerHostLabel, + explorerHostSwitchHref, + originFromHostHeader, + originsEqual, + planHostSwitch, +} from '../../internal-explorer/hosts'; + +const HOST_KEYS = ['BASE_UI_ZERONET_HOST', 'BASE_UI_MAINNET_HOST', 'BASE_UI_SEPOLIA_HOST'] as const; + +const DEPLOYED_HOSTS: Record = { + zeronet: 'https://base-ui.aws-dev.cbhq.net', + mainnet: 'https://base-ui.aws.cbhq.net', + sepolia: 'https://base-ui.aws.cbhq.net', +}; + +const previousEnv: Partial> = {}; + +beforeEach(() => { + for (const key of HOST_KEYS) { + previousEnv[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of HOST_KEYS) { + if (previousEnv[key] === undefined) delete process.env[key]; + else process.env[key] = previousEnv[key]; + } +}); + +describe('originsEqual', () => { + test('treats a Host header and a full origin as the same host', () => { + assert.equal(originsEqual('base-ui.aws-dev.cbhq.net', 'https://base-ui.aws-dev.cbhq.net'), true); + assert.equal(originsEqual('https://base-ui.aws.cbhq.net/', 'base-ui.aws.cbhq.net'), true); + }); + + test('ignores a default https port', () => { + assert.equal( + originsEqual('https://base-ui.aws.cbhq.net:443', 'https://base-ui.aws.cbhq.net'), + true, + ); + }); + + test('distinguishes aws-dev from aws prod', () => { + assert.equal( + originsEqual('https://base-ui.aws-dev.cbhq.net', 'https://base-ui.aws.cbhq.net'), + false, + ); + }); + + test('keeps a non-default port', () => { + assert.equal(originsEqual('localhost:3000', 'http://localhost:3000'), true); + assert.equal(originsEqual('localhost:3000', 'http://localhost:3001'), false); + }); + + test('rejects empty values', () => { + assert.equal(originsEqual('', 'https://base-ui.aws.cbhq.net'), false); + assert.equal(originsEqual('https://base-ui.aws.cbhq.net', ''), false); + }); +}); + +describe('defaultExplorerChainForOrigin', () => { + test('defaults to mainnet when no hosts are configured', () => { + assert.equal(defaultExplorerChainForOrigin('http://localhost:3000', {}), 'mainnet'); + assert.equal(DEFAULT_EXPLORER_CHAIN, 'mainnet'); + }); + + test('defaults to zeronet on the zeronet host', () => { + assert.equal( + defaultExplorerChainForOrigin('https://base-ui.aws-dev.cbhq.net', DEPLOYED_HOSTS), + 'zeronet', + ); + assert.equal(defaultExplorerChainForOrigin('base-ui.aws-dev.cbhq.net', DEPLOYED_HOSTS), 'zeronet'); + }); + + test('defaults to mainnet on the mainnet/sepolia host', () => { + assert.equal( + defaultExplorerChainForOrigin('https://base-ui.aws.cbhq.net', DEPLOYED_HOSTS), + 'mainnet', + ); + assert.equal(defaultExplorerChainForOrigin('base-ui.aws.cbhq.net', DEPLOYED_HOSTS), 'mainnet'); + }); + + test('falls back to mainnet when the origin matches none of the hosts', () => { + assert.equal(defaultExplorerChainForOrigin('https://example.invalid', DEPLOYED_HOSTS), 'mainnet'); + }); +}); + +describe('resolveExplorerChain', () => { + test('keeps an explicit chain even when it belongs on another host', () => { + assert.equal( + resolveExplorerChain('mainnet', 'https://base-ui.aws-dev.cbhq.net', DEPLOYED_HOSTS), + 'mainnet', + ); + assert.equal( + resolveExplorerChain('zeronet', 'https://base-ui.aws.cbhq.net', DEPLOYED_HOSTS), + 'zeronet', + ); + }); + + test('defaults from origin when ?chain= is missing', () => { + assert.equal( + resolveExplorerChain(null, 'https://base-ui.aws-dev.cbhq.net', DEPLOYED_HOSTS), + 'zeronet', + ); + assert.equal( + resolveExplorerChain(undefined, 'https://base-ui.aws.cbhq.net', DEPLOYED_HOSTS), + 'mainnet', + ); + assert.equal(resolveExplorerChain(null), 'mainnet'); + }); + + test('treats an unknown chain param as missing', () => { + assert.equal( + resolveExplorerChain('goerli', 'https://base-ui.aws.cbhq.net', DEPLOYED_HOSTS), + 'mainnet', + ); + }); +}); + +describe('planHostSwitch', () => { + test('replaces in place when the next chain is on this origin', () => { + assert.equal( + planHostSwitch('https://base-ui.aws.cbhq.net', 'sepolia', DEPLOYED_HOSTS, false), + 'replace', + ); + assert.equal( + planHostSwitch('https://base-ui.aws-dev.cbhq.net', 'zeronet', DEPLOYED_HOSTS, false), + 'replace', + ); + }); + + test('replaces in place when hosts are unset', () => { + assert.equal(planHostSwitch('http://localhost:3000', 'mainnet', {}, false), 'replace'); + assert.equal(planHostSwitch('http://localhost:3000', 'zeronet', {}, true), 'replace'); + }); + + test('prompts when the next chain is on another host', () => { + assert.equal( + planHostSwitch('https://base-ui.aws-dev.cbhq.net', 'mainnet', DEPLOYED_HOSTS, false), + 'prompt', + ); + assert.equal( + planHostSwitch('https://base-ui.aws.cbhq.net', 'zeronet', DEPLOYED_HOSTS, false), + 'prompt', + ); + }); + + test('navigates immediately when the skip-prompt flag is set', () => { + assert.equal( + planHostSwitch('https://base-ui.aws-dev.cbhq.net', 'mainnet', DEPLOYED_HOSTS, true), + 'navigate', + ); + assert.equal( + planHostSwitch('https://base-ui.aws.cbhq.net', 'zeronet', DEPLOYED_HOSTS, true), + 'navigate', + ); + }); +}); + +describe('explorerHostSwitchHref', () => { + test('assigns the destination origin with ?chain= and other query params', () => { + const href = explorerHostSwitchHref( + 'https://base-ui.aws.cbhq.net/', + '/internal-explorer/blocks', + 'tab=rejected&cursor=1', + 'mainnet', + ); + const url = new URL(href); + assert.equal(url.origin, 'https://base-ui.aws.cbhq.net'); + assert.equal(url.pathname, '/internal-explorer/blocks'); + assert.equal(url.searchParams.get('chain'), 'mainnet'); + assert.equal(url.searchParams.get('tab'), 'rejected'); + assert.equal(url.searchParams.get('cursor'), '1'); + }); + + test('overwrites an existing chain param', () => { + const href = explorerHostSwitchHref( + 'https://base-ui.aws-dev.cbhq.net', + '/internal-explorer', + 'chain=mainnet', + 'zeronet', + ); + assert.equal(new URL(href).searchParams.get('chain'), 'zeronet'); + }); +}); + +describe('host labels', () => { + test('strips the scheme for display', () => { + assert.equal(explorerHostLabel('https://base-ui.aws.cbhq.net'), 'base-ui.aws.cbhq.net'); + }); + + test('labels the zeronet host as development and the rest as production', () => { + assert.equal( + explorerHostEnvironment('https://base-ui.aws-dev.cbhq.net', DEPLOYED_HOSTS), + 'development', + ); + assert.equal(explorerHostEnvironment('https://base-ui.aws.cbhq.net', DEPLOYED_HOSTS), 'production'); + }); + + test('canonicalizes a configured host to its origin', () => { + assert.equal(configuredHostOrigin('https://base-ui.aws.cbhq.net/'), 'https://base-ui.aws.cbhq.net'); + }); + + test('prefers x-forwarded-host over Host', () => { + assert.equal( + originFromHostHeader('internal.local', 'base-ui.aws.cbhq.net, other.local'), + 'base-ui.aws.cbhq.net', + ); + }); +}); + +describe('getExplorerHosts', () => { + test('returns an empty map when BASE_UI_*_HOST is unset', () => { + assert.deepEqual(getExplorerHosts(), {}); + assert.equal(getExplorerHost('zeronet'), undefined); + }); + + test('reads BASE_UI__HOST at call time', () => { + process.env.BASE_UI_ZERONET_HOST = 'https://base-ui.aws-dev.cbhq.net'; + process.env.BASE_UI_MAINNET_HOST = 'https://base-ui.aws.cbhq.net'; + process.env.BASE_UI_SEPOLIA_HOST = 'https://base-ui.aws.cbhq.net'; + assert.deepEqual(getExplorerHosts(), DEPLOYED_HOSTS); + }); +}); + +describe('resolveExplorerChainFromRequest', () => { + test('defaults from the Host header when ?chain= is missing', () => { + process.env.BASE_UI_ZERONET_HOST = DEPLOYED_HOSTS.zeronet; + process.env.BASE_UI_MAINNET_HOST = DEPLOYED_HOSTS.mainnet; + process.env.BASE_UI_SEPOLIA_HOST = DEPLOYED_HOSTS.sepolia; + + const zeronetReq = new Request('https://base-ui.aws-dev.cbhq.net/api/internal-explorer/blocks', { + headers: { host: 'base-ui.aws-dev.cbhq.net' }, + }); + assert.equal(resolveExplorerChainFromRequest(zeronetReq), 'zeronet'); + + const prodReq = new Request('https://base-ui.aws.cbhq.net/api/internal-explorer/blocks', { + headers: { host: 'base-ui.aws.cbhq.net' }, + }); + assert.equal(resolveExplorerChainFromRequest(prodReq), 'mainnet'); + }); + + test('keeps an explicit ?chain= that does not match this host', () => { + process.env.BASE_UI_ZERONET_HOST = DEPLOYED_HOSTS.zeronet; + process.env.BASE_UI_MAINNET_HOST = DEPLOYED_HOSTS.mainnet; + process.env.BASE_UI_SEPOLIA_HOST = DEPLOYED_HOSTS.sepolia; + + const request = new Request( + 'https://base-ui.aws-dev.cbhq.net/api/internal-explorer/blocks?chain=mainnet', + { headers: { host: 'base-ui.aws-dev.cbhq.net' } }, + ); + assert.equal(resolveExplorerChainFromRequest(request), 'mainnet'); + }); + + test('defaults to mainnet when hosts are unset', () => { + const request = new Request('http://localhost:3000/api/internal-explorer/blocks', { + headers: { host: 'localhost:3000' }, + }); + assert.equal(resolveExplorerChainFromRequest(request), 'mainnet'); + }); +}); diff --git a/app/api/internal-explorer/rejected/route.ts b/app/api/internal-explorer/rejected/route.ts index fe8ff63..9f22a73 100644 --- a/app/api/internal-explorer/rejected/route.ts +++ b/app/api/internal-explorer/rejected/route.ts @@ -1,4 +1,5 @@ -import { resolveExplorerChain, type ExplorerChain } from '../../../internal-explorer/chains'; +import type { ExplorerChain } from '../../../internal-explorer/chains'; +import { resolveExplorerChainFromRequest } from '../chain'; import { getAuditRejectedTransactionEvents, rejectedTransactionFromAuditEvent, @@ -17,7 +18,7 @@ export interface RejectedTransactionsResponse { export async function GET(request: Request) { const disabled = explorerDisabledResponse(); if (disabled) return disabled; - const chain = resolveExplorerChain(new URL(request.url).searchParams.get('chain')); + const chain = resolveExplorerChainFromRequest(request); try { // Audit-first, S3 fallback: use the S3 archive only when audit is not diff --git a/app/api/internal-explorer/shadow-block/[hash]/route.ts b/app/api/internal-explorer/shadow-block/[hash]/route.ts index 77e1516..1bcb6d5 100644 --- a/app/api/internal-explorer/shadow-block/[hash]/route.ts +++ b/app/api/internal-explorer/shadow-block/[hash]/route.ts @@ -1,4 +1,4 @@ -import { resolveExplorerChain } from '../../../../internal-explorer/chains'; +import { resolveExplorerChainFromRequest } from '../../chain'; import { getShadowMetricsUrl } from '../../config'; import { explorerDisabledResponse } from '../../guard'; import { @@ -15,7 +15,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ hash if (disabled) return disabled; const url = new URL(request.url); - const chain = resolveExplorerChain(url.searchParams.get('chain')); + const chain = resolveExplorerChainFromRequest(request); const baseUrl = getShadowMetricsUrl(chain); if (!baseUrl) { return Response.json({ error: 'Shadow metrics not configured' }, { status: 503 }); diff --git a/app/api/internal-explorer/shadow-candidates-batch/route.ts b/app/api/internal-explorer/shadow-candidates-batch/route.ts index 273e168..f14d6d8 100644 --- a/app/api/internal-explorer/shadow-candidates-batch/route.ts +++ b/app/api/internal-explorer/shadow-candidates-batch/route.ts @@ -1,4 +1,4 @@ -import { resolveExplorerChain } from '../../../internal-explorer/chains'; +import { resolveExplorerChainFromRequest } from '../chain'; import { getShadowMetricsUrl } from '../config'; import { explorerDisabledResponse } from '../guard'; import { fetchShadowCandidatesBatch } from '../shadow'; @@ -13,7 +13,7 @@ export async function GET(request: Request) { if (disabled) return disabled; const url = new URL(request.url); - const chain = resolveExplorerChain(url.searchParams.get('chain')); + const chain = resolveExplorerChainFromRequest(request); const canonical = url.searchParams.get('canonical'); if (!canonical) { return Response.json({ error: 'Missing canonical hashes' }, { status: 400 }); diff --git a/app/api/internal-explorer/shadow-candidates/route.ts b/app/api/internal-explorer/shadow-candidates/route.ts index a2f1c77..cf69315 100644 --- a/app/api/internal-explorer/shadow-candidates/route.ts +++ b/app/api/internal-explorer/shadow-candidates/route.ts @@ -1,4 +1,4 @@ -import { resolveExplorerChain } from '../../../internal-explorer/chains'; +import { resolveExplorerChainFromRequest } from '../chain'; import { getShadowMetricsUrl } from '../config'; import { explorerDisabledResponse } from '../guard'; import { fetchShadowCandidates } from '../shadow'; @@ -15,7 +15,7 @@ export async function GET(request: Request) { if (disabled) return disabled; const url = new URL(request.url); - const chain = resolveExplorerChain(url.searchParams.get('chain')); + const chain = resolveExplorerChainFromRequest(request); const canonical = url.searchParams.get('canonical'); if (!canonical) { return Response.json({ error: 'Missing canonical hash' }, { status: 400 }); diff --git a/app/api/internal-explorer/txn/[hash]/route.ts b/app/api/internal-explorer/txn/[hash]/route.ts index b3d3ab9..57cfc49 100644 --- a/app/api/internal-explorer/txn/[hash]/route.ts +++ b/app/api/internal-explorer/txn/[hash]/route.ts @@ -1,4 +1,4 @@ -import { resolveExplorerChain } from '../../../../internal-explorer/chains'; +import { resolveExplorerChainFromRequest } from '../../chain'; import { explorerDisabledResponse } from '../../guard'; import { InvalidTransactionHashError, @@ -16,7 +16,7 @@ export type TransactionHistoryResponse = TransactionLookupResponse; export async function GET(request: Request, { params }: { params: Promise<{ hash: string }> }) { const disabled = explorerDisabledResponse(); if (disabled) return disabled; - const chain = resolveExplorerChain(new URL(request.url).searchParams.get('chain')); + const chain = resolveExplorerChainFromRequest(request); try { const { hash } = await params; diff --git a/app/api/internal-explorer/txs/route.ts b/app/api/internal-explorer/txs/route.ts index 6bb5a20..b972782 100644 --- a/app/api/internal-explorer/txs/route.ts +++ b/app/api/internal-explorer/txs/route.ts @@ -1,4 +1,4 @@ -import { resolveExplorerChain } from '../../../internal-explorer/chains'; +import { resolveExplorerChainFromRequest } from '../chain'; import { getRpcUrl } from '../config'; import { explorerDisabledResponse } from '../guard'; import { @@ -17,7 +17,7 @@ export type { TransactionListItem, TransactionsResponse } from '../transaction-l export async function GET(request: Request) { const disabled = explorerDisabledResponse(); if (disabled) return disabled; - const chain = resolveExplorerChain(new URL(request.url).searchParams.get('chain')); + const chain = resolveExplorerChainFromRequest(request); try { const query = parseTransactionListQuery(new URL(request.url).searchParams); diff --git a/app/internal-explorer/chains.ts b/app/internal-explorer/chains.ts index 6727d7d..b9fc128 100644 --- a/app/internal-explorer/chains.ts +++ b/app/internal-explorer/chains.ts @@ -3,6 +3,8 @@ // the URL (?chain=) and passed to /api/internal-explorer/* which resolves // per-chain S3 + RPC. +import { defaultExplorerChainForOrigin, type ExplorerHostMap } from './hosts'; + export type ExplorerChain = 'mainnet' | 'sepolia' | 'zeronet'; export type ExplorerChainInfo = { @@ -39,9 +41,14 @@ export function isExplorerChain(value: string | null | undefined): value is Expl return value === 'mainnet' || value === 'sepolia' || value === 'zeronet'; } -/** Normalize an unknown ?chain= value to a valid chain (falls back to default). */ -export function resolveExplorerChain(value: string | null | undefined): ExplorerChain { - return isExplorerChain(value) ? value : DEFAULT_EXPLORER_CHAIN; +/** Keep an explicit `?chain=`. When it is missing, default from the request origin. */ +export function resolveExplorerChain( + value: string | null | undefined, + origin?: string | null, + hosts?: ExplorerHostMap | null, +): ExplorerChain { + if (isExplorerChain(value)) return value; + return defaultExplorerChainForOrigin(origin ?? '', hosts ?? {}); } export function explorerChainInfo(chain: ExplorerChain): ExplorerChainInfo { diff --git a/app/internal-explorer/components/ChainToggle.tsx b/app/internal-explorer/components/ChainToggle.tsx index bac9b0a..54ed202 100644 --- a/app/internal-explorer/components/ChainToggle.tsx +++ b/app/internal-explorer/components/ChainToggle.tsx @@ -1,27 +1,91 @@ 'use client'; +import { useCallback, useState } from 'react'; +import { usePathname, useSearchParams } from 'next/navigation'; + import { Tabs } from '../../components/ui/Tabs'; import { trackExplorerChainSelect } from '../../analytics/events'; import { EXPLORER_CHAINS, type ExplorerChain } from '../chains'; +import { + explorerHostSwitchHref, + planHostSwitch, + readSkipHostSwitchPrompt, + writeSkipHostSwitchPrompt, +} from '../hosts'; +import { useExplorerHosts } from '../library/ExplorerHostsProvider'; import { useExplorerChain } from '../library/useExplorerChain'; +import { HostSwitchModal } from './HostSwitchModal'; // Segmented control over Internal Explorer chains (Base Mainnet / Base Sepolia / -// Zeronet). Rewrites `?chain=` via useExplorerChain's setter so the selection -// persists across navigation, and reports the choice to analytics. +// Zeronet). Same-host switches rewrite `?chain=` in place. Other-host switches +// confirm before leaving this Internal Explorer environment (or skip the prompt +// when the user previously checked "Don't show this again"). export function ChainToggle() { const { chain, setChain } = useExplorerChain(); + const { hosts } = useExplorerHosts(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [pendingChain, setPendingChain] = useState(null); - return ( - ({ value: c.id, label: c.label }))} - onChange={(value) => { - const next = value as ExplorerChain; + const goToHost = useCallback( + (next: ExplorerChain) => { + const destination = hosts[next]; + if (!destination) { + setChain(next); + return; + } + window.location.assign( + explorerHostSwitchHref(destination, pathname, searchParams.toString(), next), + ); + }, + [hosts, pathname, searchParams, setChain], + ); + + const selectChain = useCallback( + (next: ExplorerChain) => { + if (next === chain) return; + const currentOrigin = typeof window === 'undefined' ? '' : window.location.origin; + const plan = planHostSwitch(currentOrigin, next, hosts, readSkipHostSwitchPrompt()); + if (plan === 'replace') { setChain(next); trackExplorerChainSelect(next); - }} - /> + return; + } + if (plan === 'navigate') { + trackExplorerChainSelect(next); + goToHost(next); + return; + } + setPendingChain(next); + }, + [chain, goToHost, hosts, setChain], + ); + + const pendingHost = pendingChain ? hosts[pendingChain] : undefined; + + return ( + <> + ({ value: c.id, label: c.label }))} + onChange={(value) => selectChain(value as ExplorerChain)} + /> + setPendingChain(null)} + onConfirm={(dontShowAgain) => { + if (!pendingChain) return; + if (dontShowAgain) writeSkipHostSwitchPrompt(); + trackExplorerChainSelect(pendingChain); + goToHost(pendingChain); + setPendingChain(null); + }} + /> + ); } diff --git a/app/internal-explorer/components/HostSwitchModal.tsx b/app/internal-explorer/components/HostSwitchModal.tsx new file mode 100644 index 0000000..086a5df --- /dev/null +++ b/app/internal-explorer/components/HostSwitchModal.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { Button } from '../../components/ui/Button'; +import { Checkbox } from '../../components/ui/Checkbox'; +import { Modal } from '../../components/ui/Modal'; +import { Text } from '../../components/ui/Text'; +import { explorerChainInfo, type ExplorerChain } from '../chains'; +import { explorerHostEnvironment, explorerHostLabel, type ExplorerHostMap } from '../hosts'; + +type HostSwitchModalProps = { + open: boolean; + chain: ExplorerChain | null; + destinationHost: string; + hosts: ExplorerHostMap; + onCancel: () => void; + onConfirm: (dontShowAgain: boolean) => void; +}; + +export function HostSwitchModal({ + open, + chain, + destinationHost, + hosts, + onCancel, + onConfirm, +}: HostSwitchModalProps) { + const [dontShowAgain, setDontShowAgain] = useState(false); + + useEffect(() => { + if (open) setDontShowAgain(false); + }, [open]); + + const chainLabel = chain ? explorerChainInfo(chain).label : ''; + const environment = explorerHostEnvironment(destinationHost, hosts); + const hostname = explorerHostLabel(destinationHost); + + return ( + + +
+ + +
+ + } + > + + You are leaving this Internal Explorer environment for the {environment} environment at{' '} + {hostname}. Observability for {chainLabel} is served there. + +
+ ); +} diff --git a/app/internal-explorer/hosts.ts b/app/internal-explorer/hosts.ts new file mode 100644 index 0000000..f823963 --- /dev/null +++ b/app/internal-explorer/hosts.ts @@ -0,0 +1,137 @@ +// Host-aware helpers for Internal Explorer. Client-safe: the host map is +// injected at runtime (layout prop / request Host), never via NEXT_PUBLIC_*. +// +// Each chain may be served from a different origin (aws-dev vs aws prod). Same +// origin keeps `?chain=` in place; a different origin requires a confirm-and- +// navigate hop so aws-dev never silently talks to prod audit. + +import type { ExplorerChain } from './chains'; + +export type ExplorerHostMap = Partial>; + +export const SKIP_HOST_SWITCH_PROMPT_KEY = 'explorer:skip-host-switch-prompt'; + +export type HostSwitchPlan = 'replace' | 'prompt' | 'navigate'; + +function parseOrigin(value: string): { hostname: string; port: string } | null { + const trimmed = value.trim(); + if (!trimmed) return null; + try { + const url = /:\/\//.test(trimmed) ? new URL(trimmed) : new URL(`https://${trimmed}`); + let port = url.port; + if ( + (url.protocol === 'https:' && port === '443') || + (url.protocol === 'http:' && port === '80') + ) { + port = ''; + } + return { hostname: url.hostname.toLowerCase(), port }; + } catch { + return null; + } +} + +/** Compare a Host header, `window.location.origin`, or `BASE_UI_*_HOST` URL. */ +export function originsEqual(left: string, right: string): boolean { + const a = parseOrigin(left); + const b = parseOrigin(right); + if (!a || !b) return false; + return a.hostname === b.hostname && a.port === b.port; +} + +export function originFromHostHeader( + host: string | null | undefined, + forwardedHost?: string | null, +): string { + const raw = forwardedHost || host || ''; + return raw.split(',')[0]?.trim() ?? ''; +} + +export function defaultExplorerChainForOrigin( + origin: string, + hosts: ExplorerHostMap, +): ExplorerChain { + if (hosts.zeronet && originsEqual(origin, hosts.zeronet)) { + return 'zeronet'; + } + if ( + (hosts.mainnet && originsEqual(origin, hosts.mainnet)) || + (hosts.sepolia && originsEqual(origin, hosts.sepolia)) + ) { + return 'mainnet'; + } + // No hosts configured (local `npm run dev`), or an origin that matches none of + // them: mainnet, matching DEFAULT_EXPLORER_CHAIN. + return 'mainnet'; +} + +export function planHostSwitch( + currentOrigin: string, + nextChain: ExplorerChain, + hosts: ExplorerHostMap, + skipPrompt: boolean, +): HostSwitchPlan { + const nextHost = hosts[nextChain]; + if (!nextHost || originsEqual(currentOrigin, nextHost)) { + return 'replace'; + } + return skipPrompt ? 'navigate' : 'prompt'; +} + +/** Origin of a configured `BASE_UI_*_HOST` (scheme + host, no trailing slash). */ +export function configuredHostOrigin(host: string): string { + const trimmed = host.trim(); + try { + return (/:\/\//.test(trimmed) ? new URL(trimmed) : new URL(`https://${trimmed}`)).origin; + } catch { + return trimmed.replace(/\/+$/, ''); + } +} + +export function explorerHostSwitchHref( + destinationHost: string, + pathname: string, + search: string | URLSearchParams, + nextChain: ExplorerChain, +): string { + const params = new URLSearchParams(search.toString()); + params.set('chain', nextChain); + const origin = configuredHostOrigin(destinationHost); + const path = pathname.startsWith('/') ? pathname : `/${pathname}`; + const qs = params.toString(); + return `${origin}${path}${qs ? `?${qs}` : ''}`; +} + +export function explorerHostLabel(host: string): string { + const parsed = parseOrigin(host); + if (!parsed) return host.trim(); + return parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname; +} + +export function explorerHostEnvironment( + destinationHost: string, + hosts: ExplorerHostMap, +): 'development' | 'production' { + if (hosts.zeronet && originsEqual(destinationHost, hosts.zeronet)) { + return 'development'; + } + return 'production'; +} + +export function readSkipHostSwitchPrompt(): boolean { + try { + if (typeof localStorage === 'undefined') return false; + return localStorage.getItem(SKIP_HOST_SWITCH_PROMPT_KEY) === 'true'; + } catch { + return false; + } +} + +export function writeSkipHostSwitchPrompt(): void { + try { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(SKIP_HOST_SWITCH_PROMPT_KEY, 'true'); + } catch { + // Private mode can throw; the prompt will reappear next time. + } +} diff --git a/app/internal-explorer/layout.tsx b/app/internal-explorer/layout.tsx index 0912708..99d6c5d 100644 --- a/app/internal-explorer/layout.tsx +++ b/app/internal-explorer/layout.tsx @@ -1,8 +1,13 @@ import type { Metadata } from 'next'; +import { headers } from 'next/headers'; import { notFound } from 'next/navigation'; import type { ReactNode } from 'react'; +import { getExplorerHosts } from '../api/internal-explorer/config'; + import { EXPLORER_ENABLED, EXPLORER_LABEL } from './flag'; +import { originFromHostHeader } from './hosts'; +import { ExplorerHostsProvider } from './library/ExplorerHostsProvider'; // Metadata for Internal Explorer. The app-wide chrome (sidebar, header) // comes from AppShell; this layout just constrains the content column, matching @@ -13,10 +18,21 @@ export const metadata: Metadata = { 'Inspect blocks, bundles, transactions, and rejected transactions across Base chains.', }; -export default function ExplorerLayout({ children }: { children: ReactNode }) { +export default async function ExplorerLayout({ children }: { children: ReactNode }) { // Server guard: 404 the whole /internal-explorer subtree on a direct visit // when Internal Explorer is disabled. With the flag off this branch is a // compile-time constant, so the section is unreachable in the public build. if (!EXPLORER_ENABLED) notFound(); - return
{children}
; + + const headerList = await headers(); + const origin = originFromHostHeader( + headerList.get('host'), + headerList.get('x-forwarded-host'), + ); + + return ( + +
{children}
+
+ ); } diff --git a/app/internal-explorer/library/ExplorerHostsProvider.tsx b/app/internal-explorer/library/ExplorerHostsProvider.tsx new file mode 100644 index 0000000..b08c831 --- /dev/null +++ b/app/internal-explorer/library/ExplorerHostsProvider.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { createContext, useContext, type ReactNode } from 'react'; + +import type { ExplorerHostMap } from '../hosts'; + +type ExplorerHostsContextValue = { + hosts: ExplorerHostMap; + origin: string; +}; + +const ExplorerHostsContext = createContext({ + hosts: {}, + origin: '', +}); + +export function ExplorerHostsProvider({ + hosts, + origin, + children, +}: ExplorerHostsContextValue & { children: ReactNode }) { + return ( + {children} + ); +} + +export function useExplorerHosts(): ExplorerHostsContextValue { + return useContext(ExplorerHostsContext); +} diff --git a/app/internal-explorer/library/useExplorerChain.ts b/app/internal-explorer/library/useExplorerChain.ts index 8a2a15d..766e11f 100644 --- a/app/internal-explorer/library/useExplorerChain.ts +++ b/app/internal-explorer/library/useExplorerChain.ts @@ -4,6 +4,7 @@ import { useCallback } from 'react'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { resolveExplorerChain, type ExplorerChain } from '../chains'; +import { useExplorerHosts } from './ExplorerHostsProvider'; type UseExplorerChain = { /** The chain currently selected in the URL (defaults via resolveExplorerChain). */ @@ -15,12 +16,15 @@ type UseExplorerChain = { // Reads the active chain from the URL (`?chain=`) and provides a setter that // rewrites just that query param via router.replace — so the chain persists // across navigation, stays shareable, and never pushes a new history entry. +// Cross-host switches are handled by ChainToggle (confirm modal + assign), +// not here: loading with `?chain=` for another host must not redirect. export function useExplorerChain(): UseExplorerChain { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); + const { hosts, origin } = useExplorerHosts(); - const chain = resolveExplorerChain(searchParams.get('chain')); + const chain = resolveExplorerChain(searchParams.get('chain'), origin, hosts); const setChain = useCallback( (next: ExplorerChain) => {