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
22 changes: 22 additions & 0 deletions docs/WALLET_AUTH_REPLAY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Wallet authentication replay protection (#934)

## Change

`useWalletAuth` **no longer** synthesises a client-side nonce when
`authApi.requestWalletNonce` fails. A local string such as
`lumora-auth-nonce-<prefix>-<timestamp>` is not bound to server state and
can be re-signed or replayed outside the backend’s single-use nonce table.

## Model

1. Client requests a **server-issued** challenge/nonce for the public key.
2. User signs that challenge in the wallet.
3. Backend verifies the signature **and** that the nonce is unused/unexpired,
then issues a session.
4. On-chain Stellar operations remain protected by account **sequence numbers**
independently of this login challenge.

## Failure mode

If the nonce endpoint is unavailable, wallet login fails closed with an error
instead of weakening replay protection.
13 changes: 9 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ const PaymentResultScreen = lazy(() => import('@/features/payments/PaymentResult
const TransactionHistoryPage = lazy(() => import('@/features/transactions/TransactionHistoryPage'))
const ArtistWithdrawal = lazy(() => import('@/features/portfolio/ArtistWithdrawal'))

/** Shared nav link styles including WCAG 2.4.7 focus-visible ring (#939). */
const NAV_LINK_CLASSES =
'text-caption font-semibold text-muted transition-colors hover:text-foreground'
'text-caption font-semibold text-muted transition-colors hover:text-foreground rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background'

/** Tone is conveyed by glyph as well as colour (#843). */
const TOAST_GLYPHS: Record<'error' | 'success' | 'info', string> = {
Expand Down Expand Up @@ -135,6 +136,10 @@ function AppShell() {
<header className="sticky top-0 z-30 border-b border-line bg-surface/80 backdrop-blur-md">
<div className="container flex items-center justify-between gap-3 py-3.5">
<div className="flex items-center gap-6">
<Link
to="/"
className="text-caption-sm font-bold uppercase tracking-widest text-gold hover:opacity-90 transition-opacity rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
>
<Link to="/" className="text-caption-sm font-bold uppercase tracking-widest text-gold hover:opacity-90 transition-opacity">
Lumora
</Link>
Expand Down Expand Up @@ -220,9 +225,9 @@ function AppShell() {
<div className="container flex flex-col sm:flex-row items-center justify-between gap-4">
<p>Lumora — transparent, borderless crowdfunding and commissions on Stellar.</p>
<div className="flex items-center gap-4 text-caption-sm">
<Link to="/artists/elena_art" className="hover:text-foreground">Artists</Link>
<Link to="/settings/wallets" className="hover:text-foreground">Wallets</Link>
<a href="https://stellar.org" target="_blank" rel="noreferrer" className="hover:text-foreground">Stellar Network ↗</a>
<Link to="/artists/elena_art" className="hover:text-foreground rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2">Artists</Link>
<Link to="/settings/wallets" className="hover:text-foreground rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2">Wallets</Link>
<a href="https://stellar.org" target="_blank" rel="noreferrer" className="hover:text-foreground rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2">Stellar Network ↗</a>
</div>
</div>
</footer>
Expand Down
36 changes: 36 additions & 0 deletions src/components/nav/MobileNav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Link } from 'react-router-dom'
import { NAV_LINK_BASE } from './navFocus'

export interface MobileNavProps {
open: boolean
items: { to: string; label: string }[]
onNavigate?: () => void
}

/**
* Mobile disclosure nav — same focus-visible treatment as desktop (#939).
*/
export function MobileNav({ open, items, onNavigate }: MobileNavProps) {
if (!open) return null
return (
<nav
id="mobile-nav"
aria-label="Mobile"
className="sm:hidden border-t border-line bg-surface px-4 py-2"
>
<ul className="flex flex-col">
{items.map((item) => (
<li key={item.to}>
<Link
to={item.to}
className={`block py-3 ${NAV_LINK_BASE}`}
onClick={onNavigate}
>
{item.label}
</Link>
</li>
))}
</ul>
</nav>
)
}
27 changes: 27 additions & 0 deletions src/components/nav/PrimaryNav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Link } from 'react-router-dom'
import { NAV_LINK_BASE } from './navFocus'

export interface NavItem {
to: string
label: string
}

const DEFAULT_ITEMS: NavItem[] = [
{ to: '/artists/elena_art', label: 'Featured Artist' },
{ to: '/artists/stellar_nova', label: 'Explore Creators' },
]

/**
* Desktop primary navigation with visible keyboard focus indicators (#939).
*/
export function PrimaryNav({ items = DEFAULT_ITEMS }: { items?: NavItem[] }) {
return (
<nav aria-label="Primary" className="hidden sm:flex items-center gap-4">
{items.map((item) => (
<Link key={item.to} to={item.to} className={NAV_LINK_BASE}>
{item.label}
</Link>
))}
</nav>
)
}
37 changes: 37 additions & 0 deletions src/components/nav/SecondaryNav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Link } from 'react-router-dom'
import { NAV_FOCUS_CLASSES } from './navFocus'

/**
* Secondary / footer-style nav links with focus indicators (#939).
*/
export function SecondaryNav({
items,
}: {
items: { to: string; label: string; external?: boolean }[]
}) {
return (
<nav aria-label="Secondary" className="flex items-center gap-4 text-caption-sm">
{items.map((item) =>
item.external ? (
<a
key={item.to}
href={item.to}
target="_blank"
rel="noreferrer"
className={`hover:text-foreground ${NAV_FOCUS_CLASSES}`}
>
{item.label}
</a>
) : (
<Link
key={item.to}
to={item.to}
className={`hover:text-foreground ${NAV_FOCUS_CLASSES}`}
>
{item.label}
</Link>
),
)}
</nav>
)
}
16 changes: 16 additions & 0 deletions src/components/nav/__tests__/navFocus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { NAV_FOCUS_CLASSES, NAV_LINK_BASE } from '../navFocus'

describe('nav focus styles (#939)', () => {
it('includes focus-visible ring classes for WCAG 2.4.7', () => {
expect(NAV_FOCUS_CLASSES).toMatch(/focus-visible:ring-2/)
expect(NAV_FOCUS_CLASSES).toMatch(/focus-visible:ring-primary/)
expect(NAV_LINK_BASE).toContain(NAV_FOCUS_CLASSES)
})

it('does not use outline:none without a ring replacement', () => {
// outline-none is OK only when paired with ring
expect(NAV_FOCUS_CLASSES).toMatch(/focus-visible:outline-none/)
expect(NAV_FOCUS_CLASSES).toMatch(/focus-visible:ring-/)
})
})
9 changes: 9 additions & 0 deletions src/components/nav/navFocus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Shared focus-visible styles for navigation (#939 / WCAG 2.4.7).
* Uses the primary ring token (contrast ≥ 3:1 against backgrounds).
*/
export const NAV_FOCUS_CLASSES =
'rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background'

export const NAV_LINK_BASE =
`text-caption font-semibold text-muted transition-colors hover:text-foreground ${NAV_FOCUS_CLASSES}`
5 changes: 3 additions & 2 deletions src/components/ui/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ interface ErrorBoundaryState {
}

/**
* Global error boundary: catches unexpected render errors and shows a branded
* recovery screen instead of a blank page.
* Global error boundary: catches unexpected render errors, reports them to
* server monitoring via `reportError` (#936), and shows a branded recovery
* screen (friendly copy only — no stack traces in the UI).
*/
export default class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null, attempt: 0 }
Expand Down
20 changes: 20 additions & 0 deletions src/features/auth/hooks/__tests__/useWalletAuth.replay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'

/**
* Static regression guard for #934 — client-side nonce fallback must never return.
* Replay protection depends on server-issued nonces (and Stellar sequence numbers
* for on-chain ops); a local `lumora-auth-nonce-…` string is not a secure nonce.
*/
describe('useWalletAuth replay protection (#934)', () => {
it('does not contain a client-side nonce fallback', () => {
const src = readFileSync(
join(__dirname, '../useWalletAuth.ts'),
'utf8',
)
expect(src).not.toMatch(/fallbackNonce/)
expect(src).not.toMatch(/lumora-auth-nonce-/)
expect(src).toMatch(/requestWalletNonce/)
})
})
5 changes: 5 additions & 0 deletions src/features/auth/hooks/useWalletAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ export function useWalletAuth(): UseWalletAuth {
abortControllerRef.current = abortController

try {
// Step 1: Request a server-issued challenge nonce.
// Never synthesise a client-side fallback — that disables replay protection
// and lets captured signatures be reused (#934).
const nonceData = await authApi.requestWalletNonce(connection.publicKey)
}
// Step 1: Request challenge nonce from backend.
//
// A server-issued, single-use nonce is what makes this flow replay-safe.
Expand Down
31 changes: 31 additions & 0 deletions src/lib/__tests__/error-tracking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { logErrorToServer, setErrorTrackingEndpoint } from '../error-tracking'

describe('logErrorToServer (#936)', () => {
afterEach(() => {
vi.unstubAllGlobals()
setErrorTrackingEndpoint('/api/monitoring/errors')
})

it('POSTs message, stack, componentStack, and timestamp', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
setErrorTrackingEndpoint('/test/errors')

const err = new Error('boom')
await logErrorToServer(err, {
componentStack: 'at Foo',
source: 'error_boundary',
})

expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('/test/errors')
expect(init.method).toBe('POST')
const body = JSON.parse(String(init.body))
expect(body.message).toBe('boom')
expect(body.componentStack).toBe('at Foo')
expect(body.timestamp).toBeTruthy()
expect(body.source).toBe('error_boundary')
})
})
17 changes: 17 additions & 0 deletions src/lib/__tests__/theme-persistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it, beforeEach } from 'vitest'
import { getStoredTheme, persistTheme, THEME_STORAGE_KEY } from '../theme'

describe('theme persistence (#938)', () => {
beforeEach(() => {
localStorage.clear()
})

it('persists and restores the theme preference', () => {
expect(getStoredTheme()).toBeNull()
persistTheme('dark')
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
expect(getStoredTheme()).toBe('dark')
persistTheme('light')
expect(getStoredTheme()).toBe('light')
})
})
16 changes: 15 additions & 1 deletion src/lib/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export interface ErrorReportInfo {
componentStack: string | null
/** When true, skip the server monitoring POST (tests). */
skipServer?: boolean
}

export type ErrorReporter = (error: Error, info: ErrorReportInfo) => void
Expand All @@ -14,14 +16,26 @@ export function setErrorReporter(reporter: ErrorReporter | null): void {
errorReporter = reporter
}

/** Logs a render error to the console and forwards it to the registered reporter. */
/** Logs a render error to the console, server monitoring (#936), and optional reporter. */
export function reportError(error: Error, info: ErrorReportInfo): void {
if (info.componentStack) {
console.error('[ErrorBoundary] render error:', error, info.componentStack)
} else {
console.error('[ErrorBoundary] render error:', error)
}

if (!info.skipServer) {
// Dynamic import avoided for sync componentDidCatch — use fire-and-forget.
void import('./error-tracking')
.then(({ logErrorToServer }) =>
logErrorToServer(error, {
componentStack: info.componentStack,
source: 'error_boundary',
}),
)
.catch(() => {})
}

if (!errorReporter) return
try {
errorReporter(error, info)
Expand Down
57 changes: 57 additions & 0 deletions src/lib/error-tracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Server-side error monitoring (#936).
* Posts structured error payloads to the monitoring endpoint.
* User-facing UI must never display these internals.
*/

export interface ServerErrorPayload {
message: string
name: string
stack?: string
componentStack: string | null
timestamp: string
url?: string
userAgent?: string
source: 'error_boundary' | 'global_handler' | 'manual'
}

const DEFAULT_ENDPOINT = '/api/monitoring/errors'

let endpoint = DEFAULT_ENDPOINT

/** Override the monitoring endpoint (e.g. in tests). */
export function setErrorTrackingEndpoint(url: string): void {
endpoint = url
}

/**
* Fire-and-forget log of an error to the backend monitoring service.
* Never throws to callers.
*/
export async function logErrorToServer(
error: Error,
info: { componentStack?: string | null; source?: ServerErrorPayload['source'] } = {},
): Promise<void> {
const payload: ServerErrorPayload = {
message: error.message,
name: error.name,
stack: error.stack,
componentStack: info.componentStack ?? null,
timestamp: new Date().toISOString(),
url: typeof window !== 'undefined' ? window.location.href : undefined,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
source: info.source ?? 'manual',
}

try {
await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
// keepalive so the request can outlive a page unload
keepalive: true,
})
} catch {
// Monitoring must not break the app.
}
}
Loading