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) => {