diff --git a/src/app/api/install-deps/route.ts b/src/app/api/install-deps/route.ts index f15f8b8..3a327a8 100644 --- a/src/app/api/install-deps/route.ts +++ b/src/app/api/install-deps/route.ts @@ -33,8 +33,6 @@ export async function POST(req: NextRequest) { const actualNetwork = network.toLowerCase(); const shouldForceInstall = Boolean(forceInstall); - // Validate inputs to prevent path traversal attacks - // Only allow alphanumeric characters, hyphens, and underscores const safePathPattern = /^[a-zA-Z0-9_-]+$/; if (!safePathPattern.test(actualNetwork) || !safePathPattern.test(upgradeId)) { return NextResponse.json( @@ -46,9 +44,9 @@ export async function POST(req: NextRequest) { ); } - // Construct the path to the upgrade folder and lib subdirectory const contractDeploymentsPath = findContractDeploymentsRoot(); - const upgradePath = path.join(contractDeploymentsPath, actualNetwork, upgradeId); + const upgradePath = path.join(contractDeploymentsPath, 'active', 'evm'); + const taskPath = path.join(upgradePath, 'tasks', upgradeId); // Verify the resolved path is within the allowed directory let resolvedUpgradePath: string; @@ -59,12 +57,12 @@ export async function POST(req: NextRequest) { } const libPath = path.join(resolvedUpgradePath, 'lib'); + const resolvedTaskPath = assertWithinDir(taskPath, contractDeploymentsPath); - // Check if the upgrade folder exists - const upgradePathExists = await pathExists(resolvedUpgradePath); - if (!upgradePathExists) { + const taskPathExists = await pathExists(resolvedTaskPath); + if (!taskPathExists) { return NextResponse.json( - { error: `Upgrade folder not found: ${network}/${upgradeId}` }, + { error: `Task folder not found: ${path.relative(contractDeploymentsPath, taskPath)}` }, { status: 404 } ); } @@ -91,7 +89,6 @@ export async function POST(req: NextRequest) { `Installing dependencies for ${actualNetwork}/${upgradeId} (cwd: ${resolvedUpgradePath})` ); - // Run make deps in the upgrade folder const { stdout, stderr } = await execAsync('make deps', { cwd: resolvedUpgradePath, timeout: INSTALL_DEPS_TIMEOUT_MS, diff --git a/src/app/api/upgrade-config/__tests__/route.test.ts b/src/app/api/upgrade-config/__tests__/route.test.ts index 385f8d5..b3f5cd1 100644 --- a/src/app/api/upgrade-config/__tests__/route.test.ts +++ b/src/app/api/upgrade-config/__tests__/route.test.ts @@ -57,21 +57,14 @@ describe('GET /api/upgrade-config', () => { const res = await GET(createRequest({ upgradeId: 'test' })); expect(res.status).toBe(400); const body = await res.json(); - expect(body.error).toMatch(/missing required parameters/i); + expect(body.error).toMatch(/missing required parameter/i); }); it('returns 400 when upgradeId is missing', async () => { const res = await GET(createRequest({ network: 'mainnet' })); expect(res.status).toBe(400); const body = await res.json(); - expect(body.error).toMatch(/missing required parameters/i); - }); - - it('returns 400 when both are missing', async () => { - const res = await GET(createRequest({})); - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toMatch(/missing required parameters/i); + expect(body.error).toMatch(/missing required parameter/i); }); }); @@ -80,39 +73,31 @@ describe('GET /api/upgrade-config', () => { const res = await GET(createRequest({ network: '..', upgradeId: 'test' })); expect(res.status).toBe(400); const body = await res.json(); - expect(body.error).toMatch(/invalid network or upgradeId/i); + expect(body.error).toMatch(/invalid network/i); }); it('returns 400 when network contains "/"', async () => { const res = await GET(createRequest({ network: 'foo/bar', upgradeId: 'test' })); expect(res.status).toBe(400); const body = await res.json(); - expect(body.error).toMatch(/invalid network or upgradeId/i); - }); - - it('returns 400 when upgradeId contains ".."', async () => { - const res = await GET(createRequest({ network: 'mainnet', upgradeId: '..' })); - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toMatch(/invalid network or upgradeId/i); + expect(body.error).toMatch(/invalid network/i); }); it('returns 400 when network contains spaces', async () => { const res = await GET(createRequest({ network: 'main net', upgradeId: 'test' })); expect(res.status).toBe(400); const body = await res.json(); - expect(body.error).toMatch(/invalid network or upgradeId/i); + expect(body.error).toMatch(/invalid network/i); }); - it('returns 400 when upgradeId contains "@"', async () => { - const res = await GET(createRequest({ network: 'mainnet', upgradeId: 'test@1' })); + it('returns 400 when network contains "." (single dot)', async () => { + const res = await GET(createRequest({ network: '.', upgradeId: 'test' })); expect(res.status).toBe(400); const body = await res.json(); - expect(body.error).toMatch(/invalid network or upgradeId/i); + expect(body.error).toMatch(/invalid network/i); }); - - it('returns 400 when network contains "." (single dot)', async () => { - const res = await GET(createRequest({ network: '.', upgradeId: 'test' })); + it('returns 400 when upgradeId contains path separators', async () => { + const res = await GET(createRequest({ network: 'mainnet', upgradeId: 'foo/bar' })); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/invalid network or upgradeId/i); @@ -120,15 +105,10 @@ describe('GET /api/upgrade-config', () => { }); describe('valid safe characters', () => { - it('accepts hyphens (e.g., "op-mainnet")', async () => { + it('accepts hyphens in network names', async () => { const res = await GET(createRequest({ network: 'op-mainnet', upgradeId: 'test' })); expect(res.status).toBe(200); }); - - it('accepts underscores (e.g., "upgrade_1")', async () => { - const res = await GET(createRequest({ network: 'mainnet', upgradeId: 'upgrade_1' })); - expect(res.status).toBe(200); - }); }); describe('happy path', () => { diff --git a/src/app/api/upgrade-config/route.ts b/src/app/api/upgrade-config/route.ts index 5036e65..bd833ed 100644 --- a/src/app/api/upgrade-config/route.ts +++ b/src/app/api/upgrade-config/route.ts @@ -23,7 +23,6 @@ export async function GET(req: NextRequest) { ); } - // Validate inputs to prevent path traversal attacks const safePathPattern = /^[a-zA-Z0-9_-]+$/; if (!safePathPattern.test(network) || !safePathPattern.test(upgradeId)) { return NextResponse.json( @@ -38,8 +37,12 @@ export async function GET(req: NextRequest) { const contractDeploymentsRoot = findContractDeploymentsRoot(); const validationsJoinedPath = path.join( contractDeploymentsRoot, - network, + 'active', + 'evm', + 'tasks', upgradeId, + 'config', + network, 'validations' ); @@ -89,10 +92,9 @@ export async function GET(req: NextRequest) { ); console.log( - 'Found %d config options for %s/%s:', + 'Found %d config options for %s:', configOptions.length, - network, - upgradeId, + `${network}/${upgradeId}`, configOptions.map(c => c.displayName) ); diff --git a/src/app/api/upgrades/__tests__/route.test.ts b/src/app/api/upgrades/__tests__/route.test.ts index a539be5..9240e6d 100644 --- a/src/app/api/upgrades/__tests__/route.test.ts +++ b/src/app/api/upgrades/__tests__/route.test.ts @@ -54,6 +54,46 @@ describe('GET /api/upgrades', () => { expect(body[0].network).toBe(NetworkType.Zeronet); }); + it('sorts aggregated ready-to-sign tasks newest-first across networks', async () => { + // Older task on an earlier network, newer task on a later network: without a final + // sort the aggregate would follow network order (older first). + mockGetUpgradeOptions.mockImplementation((network: NetworkType) => { + if (network === NetworkType.Mainnet) { + return [ + { + id: '2025-01-01-upgrade-old', + name: 'Old', + description: '', + date: '2025-01-01', + network: NetworkType.Mainnet, + status: TaskStatus.ReadyToSign, + }, + ]; + } + if (network === NetworkType.Zeronet) { + return [ + { + id: '2025-12-31-upgrade-new', + name: 'New', + description: '', + date: '2025-12-31', + network: NetworkType.Zeronet, + status: TaskStatus.ReadyToSign, + }, + ]; + } + return []; + }); + + const res = GET(createRequest({ readyToSign: 'true' })); + const body = await res.json(); + + expect(body.map((t: DeploymentInfo) => t.id)).toEqual([ + '2025-12-31-upgrade-new', + '2025-01-01-upgrade-old', + ]); + }); + it('includes zeronet when aggregating ready-to-sign tasks', async () => { const res = GET(createRequest({ readyToSign: 'true' })); diff --git a/src/app/api/upgrades/route.ts b/src/app/api/upgrades/route.ts index 4621aa2..b3820ce 100644 --- a/src/app/api/upgrades/route.ts +++ b/src/app/api/upgrades/route.ts @@ -20,11 +20,9 @@ export function GET(req: NextRequest) { network: network, })); }); - // Sort by date (most recent first) - return NextResponse.json( - allReadyToSignTasks.sort((a, b) => b.id.localeCompare(a.id)), - { status: 200 } - ); + // Sort by id (newest first) across all networks; per-network lists are sorted independently. + allReadyToSignTasks.sort((a, b) => b.id.localeCompare(a.id)); + return NextResponse.json(allReadyToSignTasks, { status: 200 }); } return NextResponse.json({ error: 'Missing required network parameter' }, { status: 400 }); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 1890513..f414c23 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -16,6 +16,7 @@ import { ConfigOption } from '@/components/UserSelection'; import { LedgerSigningResult } from '@/lib/ledger-signing'; type Step = 'upgrade' | 'user' | 'validation' | 'ledger' | 'signing'; +type StepStatus = 'current' | 'completed' | 'pending'; const STEP_LAYOUT_WIDTH: Record = { upgrade: 'max-w-4xl', @@ -80,16 +81,11 @@ export default function Home() { setCurrentStep('user'); }; - const canEditUpgrade = - currentStep === 'user' || - currentStep === 'validation' || - currentStep === 'ledger' || - currentStep === 'signing'; + const canEditUpgrade = currentStep !== 'upgrade'; const canEditUser = currentStep === 'validation' || currentStep === 'ledger' || currentStep === 'signing'; - // Map current step to step indicator format const steps = [ { id: 'upgrade', @@ -105,10 +101,7 @@ export default function Home() { status: currentStep === 'user' ? 'current' - : ((selectedUser ? 'completed' : selectedUpgrade ? 'pending' : 'pending') as - | 'current' - | 'completed' - | 'pending'), + : ((selectedUser ? 'completed' : 'pending') as StepStatus), }, { id: 'validation', @@ -116,10 +109,7 @@ export default function Home() { status: currentStep === 'validation' ? 'current' - : ((validationData ? 'completed' : selectedUser ? 'pending' : 'pending') as - | 'current' - | 'completed' - | 'pending'), + : ((validationData ? 'completed' : 'pending') as StepStatus), }, { id: 'ledger', @@ -127,16 +117,12 @@ export default function Home() { status: currentStep === 'ledger' ? 'current' - : ((signingData ? 'completed' : validationData ? 'pending' : 'pending') as - | 'current' - | 'completed' - | 'pending'), + : ((signingData ? 'completed' : 'pending') as StepStatus), }, { id: 'signing', label: 'Confirmation', - status: - currentStep === 'signing' ? 'current' : ('pending' as 'current' | 'completed' | 'pending'), + status: currentStep === 'signing' ? 'current' : ('pending' as StepStatus), }, ]; @@ -158,8 +144,8 @@ export default function Home() { {currentStep === 'upgrade' && ( )} @@ -172,14 +158,11 @@ export default function Home() { /> )} - {currentStep === 'validation' && ( + {currentStep === 'validation' && selectedUpgrade && ( )} @@ -198,7 +181,6 @@ export default function Home() { user={selectedUser} network={selectedNetwork || ''} selectedUpgrade={{ - id: selectedUpgrade?.id || '', name: selectedUpgrade?.name || '', }} signingData={signingData} diff --git a/src/components/NetworkSelection.tsx b/src/components/NetworkSelection.tsx deleted file mode 100644 index 8460ff5..0000000 --- a/src/components/NetworkSelection.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Globe } from 'lucide-react'; -import { availableNetworks } from '@/lib/constants'; -import { NetworkType } from '@/lib/types'; - -interface NetworkSelectionProps { - onSelect: (network: NetworkType) => void; -} - -export function NetworkSelection({ onSelect }: NetworkSelectionProps) { - return ( -
-

Select Network

-
- {availableNetworks.map(option => ( - - ))} -
-
- ); -} diff --git a/src/components/SigningConfirmation.tsx b/src/components/SigningConfirmation.tsx index b867361..7647b02 100644 --- a/src/components/SigningConfirmation.tsx +++ b/src/components/SigningConfirmation.tsx @@ -10,7 +10,6 @@ interface SigningConfirmationProps { user?: ConfigOption; network: string; selectedUpgrade: { - id: string; name: string; }; signingData?: LedgerSigningResult | null; diff --git a/src/components/UpgradeSelection.tsx b/src/components/UpgradeSelection.tsx index af5ca38..c79a599 100644 --- a/src/components/UpgradeSelection.tsx +++ b/src/components/UpgradeSelection.tsx @@ -13,13 +13,13 @@ const STATUS_VARIANT_MAP: Record }; interface UpgradeSelectionProps { - selectedWallet: string | null; + selectedUpgradeId: string | null; selectedNetwork: string | null; onSelect: (upgrade: Upgrade) => void; } export const UpgradeSelection: React.FC = ({ - selectedWallet, + selectedUpgradeId, selectedNetwork, onSelect, }) => { @@ -137,12 +137,13 @@ export const UpgradeSelection: React.FC = ({
{upgradeOptions.map(option => { - const isSelected = selectedWallet === option.id && selectedNetwork === option.network; - const isExpanded = !!expandedCards[option.id]; + const optionKey = `${option.network}-${option.id}`; + const isSelected = selectedUpgradeId === option.id && selectedNetwork === option.network; + const isExpanded = !!expandedCards[optionKey]; return ( onSelect(option)} @@ -214,7 +215,7 @@ export const UpgradeSelection: React.FC = ({