Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions docs/config-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Configuration Validation Guardrails

Related: Issue #8 — "Add dashboard network and contract configuration guardrails".
See also [`docs/environment-mismatch-blocking.md`](./environment-mismatch-blocking.md)
for the separate wallet-vs-target-network check, and
[`docs/mock-mode.md`](./mock-mode.md) for local development without a live RPC
endpoint.

## Why this exists

The dashboard is configured entirely through build-time env vars:

| Variable | Purpose |
| ---------------------------------- | ---------------------------------------------------- |
| `NEXT_PUBLIC_RPC_URL` | Soroban RPC endpoint the SDK talks to |
| `NEXT_PUBLIC_NETWORK_PASSPHRASE` | Which Stellar network the dashboard targets |
| `NEXT_PUBLIC_AEGIS_CONTRACT_ID` | The contract the dashboard reads from and signs to |
| `NEXT_PUBLIC_MOCK_MODE` | Bypasses all of the above for local frontend dev |

A typo or copy-paste mistake in any of these (a malformed contract ID, a
placeholder value left over from `.env.example`, an unreachable-looking RPC
URL) previously only surfaced as an opaque runtime error once someone tried to
load data or sign a transaction — or worse, silently pointed the dashboard at
the wrong contract without any error at all.

`src/config/validate.ts` checks the **shape** of these variables at app
startup and fails loudly, before any page renders, if something's wrong.

## What is and isn't checked

Validation is a shape/format check only:

- **RPC URL** — must parse as a URL. Must be HTTPS unless it's `localhost` /
`127.0.0.1` (dev only).
- **Network passphrase** — must be present. Warns (non-blocking) if it isn't
one of the two known Stellar passphrases, since custom standalone networks
are a legitimate use case.
- **Contract ID** — must be present and match the 56-character Soroban
contract ID shape (`C` followed by 55 base32 characters).

It does **not** check that the RPC endpoint is reachable, that the contract is
actually deployed, or that the contract ID belongs to the network you think
it does — those are runtime concerns, not config concerns, and are surfaced
separately (e.g. failed SDK calls, or the wallet-network mismatch screen).

## Errors vs. warnings

- **Errors** block the dashboard entirely via `ConfigErrorScreen` — nothing
else renders until they're fixed. Missing/malformed RPC URL, missing
passphrase, and missing/malformed contract ID are all errors.
- **Warnings** are informational only and never block. Non-HTTPS remote RPC
URLs and unrecognized (custom) passphrases are warnings, since they can be
intentional.

## Mock mode bypasses validation entirely

When `NEXT_PUBLIC_MOCK_MODE="true"`, `validateDashboardConfig()` returns
valid with no issues regardless of what else is set. Mock mode already makes
no real RPC or contract calls, so there's nothing to validate — see
`docs/mock-mode.md`.

## Where to see it

- **Startup block**: `ConfigGuard` in `src/pages/_app.tsx` renders
`ConfigErrorScreen` when validation fails, before `EnvironmentGuard` (the
wallet-network mismatch check) even runs.
- **Always-on banner**: `EnvironmentBanner` (`src/components/EnvironmentBanner.tsx`)
shows the current target network and a redacted contract ID on every page,
so you always know which environment you're pointed at — separate from the
mock-mode banner and the mismatch-blocking screen.
- **Diagnostics**: the "Config Validation" card in `DiagnosticsPanel` shows a
non-sensitive summary (valid/invalid, issue counts, and which field names
have issues — never raw values) that's safe to include in a copied
diagnostics report.

## Fixing a validation error

1. Open the `ConfigErrorScreen` message — it names the exact env var and what's
wrong with it.
2. Update the value in your `.env.local` (see `.env.example` for the expected
format of each variable).
3. Restart the dev server (Next.js inlines `NEXT_PUBLIC_*` vars at build time,
so changes to `.env.local` require a restart to take effect).

If you don't have real RPC/contract config yet (e.g. you're just working on
UI), set `NEXT_PUBLIC_MOCK_MODE="true"` instead of trying to fill these in
with placeholder values.
58 changes: 58 additions & 0 deletions src/components/ConfigErrorScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import ConfigErrorScreen from './ConfigErrorScreen';
import type { ConfigValidationResult } from '@/config/validate';

const resultWithErrorsAndWarnings: ConfigValidationResult = {
valid: false,
issues: [
{ field: 'NEXT_PUBLIC_RPC_URL', level: 'error', message: 'RPC URL is not set.' },
{
field: 'NEXT_PUBLIC_NETWORK_PASSPHRASE',
level: 'warning',
message: 'Passphrase is not one of the known Stellar PUBLIC/TESTNET passphrases.',
},
],
};

const resultErrorsOnly: ConfigValidationResult = {
valid: false,
issues: [
{
field: 'NEXT_PUBLIC_AEGIS_CONTRACT_ID',
level: 'error',
message: 'Contract ID is not a valid Soroban contract ID (expected 56 characters, starting with "C").',
},
],
};

describe('ConfigErrorScreen', () => {
it('renders the heading', () => {
render(<ConfigErrorScreen result={resultWithErrorsAndWarnings} />);
expect(screen.getByText('Dashboard configuration is invalid')).toBeInTheDocument();
});

it('lists error-level issues under an Errors heading', () => {
render(<ConfigErrorScreen result={resultWithErrorsAndWarnings} />);
expect(screen.getByText('Errors')).toBeInTheDocument();
expect(screen.getByText('NEXT_PUBLIC_RPC_URL')).toBeInTheDocument();
expect(screen.getByText(/RPC URL is not set\./)).toBeInTheDocument();
});

it('lists warning-level issues under a Warnings heading', () => {
render(<ConfigErrorScreen result={resultWithErrorsAndWarnings} />);
expect(screen.getByText('Warnings')).toBeInTheDocument();
expect(screen.getByText('NEXT_PUBLIC_NETWORK_PASSPHRASE')).toBeInTheDocument();
});

it('omits the Warnings heading when there are no warnings', () => {
render(<ConfigErrorScreen result={resultErrorsOnly} />);
expect(screen.queryByText('Warnings')).not.toBeInTheDocument();
});

it('points to the docs and mock mode as a way forward', () => {
render(<ConfigErrorScreen result={resultErrorsOnly} />);
expect(screen.getByText(/docs\/config-validation\.md/)).toBeInTheDocument();
expect(screen.getByText(/NEXT_PUBLIC_MOCK_MODE/)).toBeInTheDocument();
});
});
88 changes: 88 additions & 0 deletions src/components/ConfigErrorScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* ConfigErrorScreen
*
* Renders when the dashboard's own environment configuration fails
* validation (see src/config/validate.ts, Issue #8). This is deliberately a
* hard stop, not a dismissible banner — a misconfigured RPC URL or contract
* ID could otherwise let a user sign an action against the wrong network or
* contract without ever realizing it.
*
* Only ever shown for "error"-level issues; "warning"-level issues are
* listed here too for visibility but never block on their own.
*/

import { AlertTriangle, AlertCircle } from 'lucide-react';
import type { ConfigValidationResult, ConfigIssue } from '@/config/validate';

interface ConfigErrorScreenProps {
result: ConfigValidationResult;
}

function IssueRow({ issue }: { issue: ConfigIssue }) {
const isError = issue.level === 'error';
return (
<li
className={
isError
? 'flex gap-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-slate-700'
: 'flex gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-slate-700'
}
>
{isError ? (
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" aria-hidden="true" />
) : (
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" aria-hidden="true" />
)}
<span>
<span className="font-mono font-semibold">{issue.field}</span>: {issue.message}
</span>
</li>
);
}

export default function ConfigErrorScreen({ result }: ConfigErrorScreenProps) {
const errors = result.issues.filter((issue) => issue.level === 'error');
const warnings = result.issues.filter((issue) => issue.level === 'warning');

return (
<div className="max-w-2xl mx-auto py-20 px-4" role="alert" aria-live="polite">
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
<div className="mb-6 text-center">
<AlertTriangle size={48} className="mx-auto mb-4 text-red-500" aria-hidden="true" />
<h2 className="mb-1 text-2xl font-bold text-slate-900">Dashboard configuration is invalid</h2>
<p className="text-slate-600">
Fix the issues below in your environment configuration before continuing.
</p>
</div>

{errors.length > 0 && (
<div className="mb-4">
<h3 className="mb-2 text-sm font-semibold text-red-700">Errors</h3>
<ul className="space-y-2">
{errors.map((issue) => (
<IssueRow key={issue.field} issue={issue} />
))}
</ul>
</div>
)}

{warnings.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold text-amber-700">Warnings</h3>
<ul className="space-y-2">
{warnings.map((issue) => (
<IssueRow key={issue.field} issue={issue} />
))}
</ul>
</div>
)}

<p className="mt-6 text-center text-xs text-slate-400">
See docs/config-validation.md for setup instructions, or set{' '}
<code className="font-mono">NEXT_PUBLIC_MOCK_MODE=&quot;true&quot;</code> for local frontend
development without real RPC/contract config.
</p>
</div>
</div>
);
}
39 changes: 39 additions & 0 deletions src/components/EnvironmentBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import EnvironmentBanner from './EnvironmentBanner';

const ORIGINAL_ENV = process.env;
const VALID_CONTRACT_ID = 'C' + 'A'.repeat(55);

beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
process.env.NEXT_PUBLIC_MOCK_MODE = 'false';
process.env.NEXT_PUBLIC_AEGIS_CONTRACT_ID = VALID_CONTRACT_ID;
});

describe('EnvironmentBanner', () => {
it('renders nothing when mock mode is active', () => {
process.env.NEXT_PUBLIC_MOCK_MODE = 'true';
const { container } = render(<EnvironmentBanner />);
expect(container).toBeEmptyDOMElement();
});

it('shows the testnet label for the TESTNET passphrase', () => {
process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE = 'Test SDF Network ; September 2015';
render(<EnvironmentBanner />);
expect(screen.getByText('Stellar Testnet (TESTNET)')).toBeInTheDocument();
});

it('shows a distinct LIVE MAINNET label for the PUBLIC passphrase', () => {
process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE = 'Public Global Stellar Network ; September 2015';
render(<EnvironmentBanner />);
expect(screen.getByText('LIVE MAINNET')).toBeInTheDocument();
});

it('shows a redacted contract id, never the full id', () => {
process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE = 'Test SDF Network ; September 2015';
render(<EnvironmentBanner />);
expect(screen.queryByText(VALID_CONTRACT_ID)).not.toBeInTheDocument();
expect(screen.getByText(`${VALID_CONTRACT_ID.slice(0, 4)}...${VALID_CONTRACT_ID.slice(-4)}`)).toBeInTheDocument();
});
});
46 changes: 46 additions & 0 deletions src/components/EnvironmentBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* EnvironmentBanner
*
* Always-visible strip that shows which network and contract the dashboard
* is currently pointed at (Issue #8). This is distinct from:
* - MockModeBanner, which only renders when mock mode is active, and
* - EnvironmentMismatchScreen, which blocks on a *wallet* network mismatch.
*
* EnvironmentBanner renders unconditionally (outside of mock mode) so a user
* can tell which environment they're in at a glance, even before connecting
* a wallet. Mainnet gets a visually distinct, harder-to-miss treatment since
* real funds are at stake there.
*/

import { getTargetNetwork, formatNetworkLabel } from '@/lib/environment';
import { redactContractId } from '@/lib/diagnostics/redact';
import { isMockModeEnabled } from '@/config/mockMode';

const MAINNET_PASSPHRASE = 'Public Global Stellar Network ; September 2015';

export default function EnvironmentBanner() {
// Mock mode has its own, more prominent banner — avoid showing both.
if (isMockModeEnabled()) return null;

const passphrase = getTargetNetwork();
const label = formatNetworkLabel(passphrase);
const contractId = process.env.NEXT_PUBLIC_AEGIS_CONTRACT_ID;
const isMainnet = passphrase === MAINNET_PASSPHRASE;

return (
<div
role="status"
className={
isMainnet
? 'flex items-center justify-center gap-2 bg-red-600 px-4 py-1.5 text-xs font-semibold text-white'
: 'flex items-center justify-center gap-2 bg-slate-700 px-4 py-1.5 text-xs font-medium text-slate-100'
}
>
<span>{isMainnet ? 'LIVE MAINNET' : label}</span>
<span aria-hidden="true" className="opacity-60">
</span>
<span className="font-mono">{redactContractId(contractId)}</span>
</div>
);
}
Loading
Loading