From 15ebc943d22d7556a1a1d3a610006202faeff866 Mon Sep 17 00:00:00 2001 From: Montana Wong Date: Wed, 19 Aug 2026 10:32:03 -0400 Subject: [PATCH] feat(tips): gate the chain switcher on a runtime TIPS_CHAINS allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal deployment serves different chains per environment: production reaches the mainnet and sepolia bundle-history buckets, development reaches zeronet. Until now the switcher offered all three everywhere, so production showed a Zeronet tab it has no data for. NEXT_PUBLIC_DEPLOY_TARGET cannot express this. It is inlined at build time, and the internal target builds one image that is promoted from development to production — the two environments differ only in runtime env. So this reads a new TIPS_CHAINS allowlist per request instead: - app/tips/enabledChains.ts resolves it server-side (server-only: TIPS_CHAINS is not a NEXT_PUBLIC_ var). - TipsChainsProvider carries the list into the client tree, which cannot read the env itself; the section layout resolves it once per request. - ChainToggle renders only the enabled chains, and hides itself entirely when there is only one. - useTipsChain clamps `?chain=` to the enabled list, so a shared zeronet link opened against production reads as mainnet rather than erroring. - The TIPS API routes 404 a chain this deployment does not serve, rather than falling through to per-chain config that is unset here — which would read the default bucket and either 500 or answer from the wrong source. Unset means every known chain, so local dev and any deployment that does not set it behave exactly as before. --- README.md | 9 +++ app/api/tips/block/[hash]/route.ts | 4 +- app/api/tips/blocks/route.ts | 4 +- app/api/tips/bundle/[hash]/route.ts | 4 +- app/api/tips/guard.ts | 14 ++++ app/api/tips/rejected/route.ts | 4 +- app/api/tips/txn/[hash]/route.ts | 4 +- app/tips/chains.test.ts | 87 ++++++++++++++++++++++ app/tips/chains.ts | 46 ++++++++++-- app/tips/components/ChainToggle.tsx | 15 ++-- app/tips/components/TipsChainsProvider.tsx | 32 ++++++++ app/tips/enabledChains.ts | 20 +++++ app/tips/layout.tsx | 10 ++- app/tips/library/useTipsChain.ts | 8 +- 14 files changed, 242 insertions(+), 19 deletions(-) create mode 100644 app/tips/chains.test.ts create mode 100644 app/tips/components/TipsChainsProvider.tsx create mode 100644 app/tips/enabledChains.ts diff --git a/README.md b/README.md index 15e844c..789ea20 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,15 @@ To add an environment-specific page, add one `SURFACES` entry (middleware and th llms generator pick it up automatically) and gate its nav entry / layout / API guard on `surfaceEnabled(...)`. `deploy.config.test.mjs` covers the matrix logic. +`NEXT_PUBLIC_DEPLOY_TARGET` is a **build-time** switch, so it can only separate +deployments that are built separately. The internal target builds one image and +promotes it between environments, so anything that must differ *within* the +internal target has to be runtime config instead. `TIPS_CHAINS` is the current +example: a comma-separated allowlist (`mainnet,sepolia`) of the chains the TIPS +chain switcher offers and its API will serve, read per request in +`app/tips/enabledChains.ts`. Unset — the default, including local dev — means +every known chain. + ## Deployment Deployed on Vercel (external target). Push to the default branch to ship; pull diff --git a/app/api/tips/block/[hash]/route.ts b/app/api/tips/block/[hash]/route.ts index fd808f5..720f7b1 100644 --- a/app/api/tips/block/[hash]/route.ts +++ b/app/api/tips/block/[hash]/route.ts @@ -10,7 +10,7 @@ import { transactionMetadataFromAuditEvents, } from '../../audit-events'; import { getAuditRpcUrl, getRpcUrl } from '../../config'; -import { tipsDisabledResponse } from '../../guard'; +import { tipsChainDisabledResponse, tipsDisabledResponse } from '../../guard'; import { getTransactionReceiptSummaries } from '../../receipts'; import { cacheBlockData, @@ -305,6 +305,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ hash const disabled = tipsDisabledResponse(); if (disabled) return disabled; const chain = resolveTipsChain(new URL(request.url).searchParams.get('chain')); + const chainDisabled = tipsChainDisabledResponse(chain); + if (chainDisabled) return chainDisabled; const rpcUrl = getRpcUrl(chain); try { diff --git a/app/api/tips/blocks/route.ts b/app/api/tips/blocks/route.ts index ed746b5..4d9ec88 100644 --- a/app/api/tips/blocks/route.ts +++ b/app/api/tips/blocks/route.ts @@ -6,7 +6,7 @@ import { parseBlockListQuery, } from '../block-list'; import { getRpcUrl } from '../config'; -import { tipsDisabledResponse } from '../guard'; +import { tipsChainDisabledResponse, tipsDisabledResponse } from '../guard'; export const runtime = 'nodejs'; @@ -18,6 +18,8 @@ export async function GET(request: Request) { const disabled = tipsDisabledResponse(); if (disabled) return disabled; const chain = resolveTipsChain(new URL(request.url).searchParams.get('chain')); + const chainDisabled = tipsChainDisabledResponse(chain); + if (chainDisabled) return chainDisabled; try { const query = parseBlockListQuery(new URL(request.url).searchParams); diff --git a/app/api/tips/bundle/[hash]/route.ts b/app/api/tips/bundle/[hash]/route.ts index 65ea320..5833f3a 100644 --- a/app/api/tips/bundle/[hash]/route.ts +++ b/app/api/tips/bundle/[hash]/route.ts @@ -6,7 +6,7 @@ import { getJoinedAuditEventsByBundle, } from '../../audit-events'; import { getAuditRpcUrl, getRpcUrl } from '../../config'; -import { tipsDisabledResponse } from '../../guard'; +import { tipsChainDisabledResponse, tipsDisabledResponse } from '../../guard'; import { getBundleHistory } from '../../s3'; import type { BundleEvent, BundleHistory, BundleTransaction } from '../../transaction-data'; import { publicClientFor, type TipsPublicClient } from '../../viem'; @@ -96,6 +96,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ hash const disabled = tipsDisabledResponse(); if (disabled) return disabled; const chain = resolveTipsChain(new URL(request.url).searchParams.get('chain')); + const chainDisabled = tipsChainDisabledResponse(chain); + if (chainDisabled) return chainDisabled; try { const { hash } = await params; diff --git a/app/api/tips/guard.ts b/app/api/tips/guard.ts index 7a1197f..36fd29a 100644 --- a/app/api/tips/guard.ts +++ b/app/api/tips/guard.ts @@ -1,3 +1,5 @@ +import type { TipsChain } from '../../tips/chains'; +import { isTipsChainEnabled } from '../../tips/enabledChains'; import { TIPS_ENABLED } from '../../tips/flag'; // Returns a 404 Response when TIPS is disabled (the public/Vercel build), else @@ -7,3 +9,15 @@ import { TIPS_ENABLED } from '../../tips/flag'; export function tipsDisabledResponse(): Response | null { return TIPS_ENABLED ? null : Response.json({ error: 'Not found' }, { status: 404 }); } + +// Returns a 404 Response when this deployment does not serve `chain`, else +// null. The UI never asks for a disabled chain — useTipsChain clamps `?chain=` +// to the enabled list — so this catches hand-edited URLs and stale links. 404 +// rather than a silent fall back to the default chain, which would return one +// chain's data under another chain's name; and 404 rather than letting the +// request through to per-chain config that is unset in this environment, where +// it would read the default bucket and 500 or, worse, succeed against the +// wrong source. +export function tipsChainDisabledResponse(chain: TipsChain): Response | null { + return isTipsChainEnabled(chain) ? null : Response.json({ error: 'Not found' }, { status: 404 }); +} diff --git a/app/api/tips/rejected/route.ts b/app/api/tips/rejected/route.ts index caf2d08..fbb9cc3 100644 --- a/app/api/tips/rejected/route.ts +++ b/app/api/tips/rejected/route.ts @@ -4,7 +4,7 @@ import { rejectedTransactionFromAuditEvent, } from '../audit-events'; import { getAuditRpcUrl } from '../config'; -import { tipsDisabledResponse } from '../guard'; +import { tipsChainDisabledResponse, tipsDisabledResponse } from '../guard'; import { getRejectedTransaction, listRejectedTransactions } from '../s3'; import type { RejectedTransaction } from '../transaction-data'; @@ -18,6 +18,8 @@ export async function GET(request: Request) { const disabled = tipsDisabledResponse(); if (disabled) return disabled; const chain = resolveTipsChain(new URL(request.url).searchParams.get('chain')); + const chainDisabled = tipsChainDisabledResponse(chain); + if (chainDisabled) return chainDisabled; try { // Audit-first, S3 fallback: use the S3 archive only when audit is not diff --git a/app/api/tips/txn/[hash]/route.ts b/app/api/tips/txn/[hash]/route.ts index 3d0f2a1..fa7307b 100644 --- a/app/api/tips/txn/[hash]/route.ts +++ b/app/api/tips/txn/[hash]/route.ts @@ -1,5 +1,5 @@ import { resolveTipsChain } from '../../../../tips/chains'; -import { tipsDisabledResponse } from '../../guard'; +import { tipsChainDisabledResponse, tipsDisabledResponse } from '../../guard'; import { InvalidTransactionHashError, lookupTransaction, @@ -17,6 +17,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ hash const disabled = tipsDisabledResponse(); if (disabled) return disabled; const chain = resolveTipsChain(new URL(request.url).searchParams.get('chain')); + const chainDisabled = tipsChainDisabledResponse(chain); + if (chainDisabled) return chainDisabled; try { const { hash } = await params; diff --git a/app/tips/chains.test.ts b/app/tips/chains.test.ts new file mode 100644 index 0000000..23b058a --- /dev/null +++ b/app/tips/chains.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { ALL_TIPS_CHAINS, parseTipsChains, resolveTipsChain } from './chains'; +import { enabledTipsChains, isTipsChainEnabled } from './enabledChains'; + +describe('parseTipsChains', () => { + it('treats unset and empty as every known chain', () => { + expect(parseTipsChains(undefined)).toEqual([...ALL_TIPS_CHAINS]); + expect(parseTipsChains(null)).toEqual([...ALL_TIPS_CHAINS]); + expect(parseTipsChains('')).toEqual([...ALL_TIPS_CHAINS]); + }); + + it('parses an allowlist, tolerating whitespace and case', () => { + expect(parseTipsChains('mainnet,sepolia')).toEqual(['mainnet', 'sepolia']); + expect(parseTipsChains(' MAINNET , Sepolia ')).toEqual(['mainnet', 'sepolia']); + }); + + it('keeps catalogue order and drops duplicates', () => { + expect(parseTipsChains('zeronet,mainnet,mainnet')).toEqual(['mainnet', 'zeronet']); + }); + + it('drops unknown names but keeps the recognized ones', () => { + expect(parseTipsChains('mainnet,nope')).toEqual(['mainnet']); + }); + + it('falls back to every chain when nothing recognizable is named', () => { + // A typo should not empty the section; unset semantics are the safer default. + expect(parseTipsChains('nope,alsonope')).toEqual([...ALL_TIPS_CHAINS]); + }); +}); + +describe('resolveTipsChain', () => { + it('defaults to mainnet for missing or unknown values', () => { + expect(resolveTipsChain(null)).toBe('mainnet'); + expect(resolveTipsChain('nope')).toBe('mainnet'); + }); + + it('returns the requested chain when it is enabled', () => { + expect(resolveTipsChain('zeronet')).toBe('zeronet'); + expect(resolveTipsChain('sepolia', ['mainnet', 'sepolia'])).toBe('sepolia'); + }); + + it('falls back to the default when the requested chain is not served here', () => { + // The production case: a zeronet link opened against the prod deployment. + expect(resolveTipsChain('zeronet', ['mainnet', 'sepolia'])).toBe('mainnet'); + }); + + it('falls back to the first enabled chain when the default is not served', () => { + expect(resolveTipsChain('mainnet', ['sepolia'])).toBe('sepolia'); + expect(resolveTipsChain('zeronet', ['sepolia', 'zeronet'])).toBe('zeronet'); + }); +}); + +describe('enabledTipsChains', () => { + const original = process.env.TIPS_CHAINS; + afterEach(() => { + if (original === undefined) delete process.env.TIPS_CHAINS; + else process.env.TIPS_CHAINS = original; + }); + + it('serves every chain when TIPS_CHAINS is unset (local dev)', () => { + delete process.env.TIPS_CHAINS; + expect(enabledTipsChains()).toEqual([...ALL_TIPS_CHAINS]); + expect(isTipsChainEnabled('zeronet')).toBe(true); + }); + + it('honours the production allowlist', () => { + process.env.TIPS_CHAINS = 'mainnet,sepolia'; + expect(enabledTipsChains()).toEqual(['mainnet', 'sepolia']); + expect(isTipsChainEnabled('mainnet')).toBe(true); + expect(isTipsChainEnabled('sepolia')).toBe(true); + expect(isTipsChainEnabled('zeronet')).toBe(false); + }); + + it('honours the development allowlist', () => { + process.env.TIPS_CHAINS = 'mainnet,sepolia,zeronet'; + expect(enabledTipsChains()).toEqual(['mainnet', 'sepolia', 'zeronet']); + expect(isTipsChainEnabled('zeronet')).toBe(true); + }); + + it('is read per call, not cached at module load', () => { + process.env.TIPS_CHAINS = 'mainnet'; + expect(enabledTipsChains()).toEqual(['mainnet']); + process.env.TIPS_CHAINS = 'mainnet,zeronet'; + expect(enabledTipsChains()).toEqual(['mainnet', 'zeronet']); + }); +}); diff --git a/app/tips/chains.ts b/app/tips/chains.ts index e454e67..f3d37d0 100644 --- a/app/tips/chains.ts +++ b/app/tips/chains.ts @@ -24,9 +24,10 @@ function explorerFor(chain: TipsChain): string { return configured && configured.length > 0 ? configured : DEFAULT_EXPLORERS[chain]; } -export const TIPS_CHAINS: readonly TipsChainInfo[] = ( - ['mainnet', 'sepolia', 'zeronet'] as const -).map((id) => ({ +/** Every chain the TIPS surface knows how to render, in display order. */ +export const ALL_TIPS_CHAINS = ['mainnet', 'sepolia', 'zeronet'] as const; + +export const TIPS_CHAINS: readonly TipsChainInfo[] = ALL_TIPS_CHAINS.map((id) => ({ id, label: id === 'mainnet' ? 'Base Mainnet' : id === 'sepolia' ? 'Base Sepolia' : 'Zeronet', explorerUrl: explorerFor(id), @@ -38,9 +39,42 @@ export function isTipsChain(value: string | null | undefined): value is TipsChai return value === 'mainnet' || value === 'sepolia' || value === 'zeronet'; } -/** Normalize an unknown ?chain= value to a valid chain (falls back to default). */ -export function resolveTipsChain(value: string | null | undefined): TipsChain { - return isTipsChain(value) ? value : DEFAULT_TIPS_CHAIN; +/** + * Normalize an unknown ?chain= value to a chain that is actually available. + * + * `enabled` defaults to every known chain, so callers with no deployment + * context behave as before. When the requested chain is absent from `enabled` + * — a stale link, or a URL hand-edited to a chain this deployment does not + * serve — this falls back to the default chain if it is enabled, else to the + * first enabled one, so the caller always gets a chain it can serve. + */ +export function resolveTipsChain( + value: string | null | undefined, + enabled: readonly TipsChain[] = ALL_TIPS_CHAINS, +): TipsChain { + if (enabled.length === 0) return DEFAULT_TIPS_CHAIN; + if (isTipsChain(value) && enabled.includes(value)) return value; + return enabled.includes(DEFAULT_TIPS_CHAIN) ? DEFAULT_TIPS_CHAIN : enabled[0]; +} + +/** + * Parse a `TIPS_CHAINS` allowlist ("mainnet,sepolia") into chain ids. + * + * Unset or empty means every known chain — the local-dev and pre-configuration + * default, which keeps behaviour unchanged for deployments that do not set it. + * Unknown names are dropped rather than failing the request: the env var is + * operator-supplied, and a typo should not take the whole section down. A value + * naming only unknown chains is treated as unset for the same reason. + */ +export function parseTipsChains(raw: string | null | undefined): readonly TipsChain[] { + if (!raw) return ALL_TIPS_CHAINS; + const named = raw + .split(',') + .map((part) => part.trim().toLowerCase()) + .filter(isTipsChain); + if (named.length === 0) return ALL_TIPS_CHAINS; + // Keep the catalogue's display order and drop duplicates. + return ALL_TIPS_CHAINS.filter((id) => named.includes(id)); } export function tipsChainInfo(chain: TipsChain): TipsChainInfo { diff --git a/app/tips/components/ChainToggle.tsx b/app/tips/components/ChainToggle.tsx index e99986d..ae2d2df 100644 --- a/app/tips/components/ChainToggle.tsx +++ b/app/tips/components/ChainToggle.tsx @@ -2,21 +2,26 @@ import { Tabs } from '../../components/ui/Tabs'; import { trackTipsChainSelect } from '../../analytics/events'; -import { TIPS_CHAINS, type TipsChain } from '../chains'; +import { tipsChainInfo, type TipsChain } from '../chains'; import { useTipsChain } from '../library/useTipsChain'; +import { useEnabledTipsChains } from './TipsChainsProvider'; -// Segmented control over the TIPS chains (Base Mainnet / Base Sepolia / -// Zeronet). Rewrites `?chain=` via useTipsChain's setter so the selection -// persists across navigation, and reports the choice to analytics. +// Segmented control over the chains this deployment serves. Rewrites `?chain=` +// via useTipsChain's setter so the selection persists across navigation, and +// reports the choice to analytics. Hidden when there is nothing to choose +// between — a one-chain deployment gets a label-less single tab otherwise. export function ChainToggle() { const { chain, setChain } = useTipsChain(); + const enabled = useEnabledTipsChains(); + + if (enabled.length < 2) return null; return ( ({ value: c.id, label: c.label }))} + items={enabled.map((id) => ({ value: id, label: tipsChainInfo(id).label }))} onChange={(value) => { const next = value as TipsChain; setChain(next); diff --git a/app/tips/components/TipsChainsProvider.tsx b/app/tips/components/TipsChainsProvider.tsx new file mode 100644 index 0000000..80c1a6b --- /dev/null +++ b/app/tips/components/TipsChainsProvider.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { createContext, useContext, type ReactNode } from 'react'; + +import { ALL_TIPS_CHAINS, type TipsChain } from '../chains'; + +// Carries the deployment's chain allowlist from the server (TIPS_CHAINS, read +// in app/tips/enabledChains.ts) down to the client components that render and +// resolve the chain. The list is runtime config the client cannot read itself, +// so the section layout resolves it once and provides it here. +const TipsChainsContext = createContext(ALL_TIPS_CHAINS); + +export function TipsChainsProvider({ + chains, + children, +}: { + chains: readonly TipsChain[]; + children: ReactNode; +}) { + return {children}; +} + +/** + * The chains this deployment serves, in display order. + * + * Defaults to every known chain when no provider is present, matching the + * unset-TIPS_CHAINS behaviour so a component rendered outside the section + * (or in a test) still works. + */ +export function useEnabledTipsChains(): readonly TipsChain[] { + return useContext(TipsChainsContext); +} diff --git a/app/tips/enabledChains.ts b/app/tips/enabledChains.ts new file mode 100644 index 0000000..c2efbcc --- /dev/null +++ b/app/tips/enabledChains.ts @@ -0,0 +1,20 @@ +// Which TIPS chains this deployment serves. Server-only: reads TIPS_CHAINS, +// a plain (non-NEXT_PUBLIC_) env var, so it must never be imported from a +// client component — the client gets the list through TipsChainsProvider. +// +// This is deliberately runtime rather than build-time config. The internal +// deployment builds one image (protocols/ui Dockerfile.ui) and promotes that +// same image from development to production, so a NEXT_PUBLIC_* flag — inlined +// into the bundle at build time — cannot differ between the two environments. +// The Helm chart already varies per-chain TIPS_* env this way; TIPS_CHAINS +// joins it. Unset means every known chain, which is what local dev sees. +import { parseTipsChains, type TipsChain } from './chains'; + +export function enabledTipsChains(): readonly TipsChain[] { + return parseTipsChains(process.env.TIPS_CHAINS); +} + +/** Is this chain served by this deployment? */ +export function isTipsChainEnabled(chain: TipsChain): boolean { + return enabledTipsChains().includes(chain); +} diff --git a/app/tips/layout.tsx b/app/tips/layout.tsx index 1567134..d8a1060 100644 --- a/app/tips/layout.tsx +++ b/app/tips/layout.tsx @@ -2,6 +2,8 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import type { ReactNode } from 'react'; +import { TipsChainsProvider } from './components/TipsChainsProvider'; +import { enabledTipsChains } from './enabledChains'; import { TIPS_ENABLED } from './flag'; // Metadata for the TIPS section. The app-wide chrome (sidebar, header) comes @@ -18,5 +20,11 @@ export default function TipsLayout({ children }: { children: ReactNode }) { // disabled. With the flag off this branch is a compile-time constant, so the // section is unreachable in the public build. if (!TIPS_ENABLED) notFound(); - return
{children}
; + // Resolved here, once per request, because TIPS_CHAINS is server-only runtime + // config: the client components below cannot read it themselves. + return ( + +
{children}
+
+ ); } diff --git a/app/tips/library/useTipsChain.ts b/app/tips/library/useTipsChain.ts index fe9ec1b..e785da2 100644 --- a/app/tips/library/useTipsChain.ts +++ b/app/tips/library/useTipsChain.ts @@ -4,9 +4,10 @@ import { useCallback } from 'react'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { resolveTipsChain, type TipsChain } from '../chains'; +import { useEnabledTipsChains } from '../components/TipsChainsProvider'; type UseTipsChain = { - /** The chain currently selected in the URL (defaults via resolveTipsChain). */ + /** The chain selected in the URL, clamped to the chains this deployment serves. */ chain: TipsChain; /** Update `?chain=` in place, preserving the path and other query params. */ setChain: (next: TipsChain) => void; @@ -20,7 +21,10 @@ export function useTipsChain(): UseTipsChain { const pathname = usePathname(); const searchParams = useSearchParams(); - const chain = resolveTipsChain(searchParams.get('chain')); + // Clamped to what this deployment serves, so a `?chain=` naming a chain this + // environment has no data for reads as the default rather than erroring. + const enabled = useEnabledTipsChains(); + const chain = resolveTipsChain(searchParams.get('chain'), enabled); const setChain = useCallback( (next: TipsChain) => {