diff --git a/app/src/App.tsx b/app/src/App.tsx index 146a829..1cc88fb 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -20,23 +20,25 @@ import { useAppStore } from './stores/app' import { useAuth } from './hooks/useAuth' import { useSSE } from './hooks/useSSE' import { useNetworkConfigStore, fetchNetworkConfig } from './lib/networkConfig' +import { ToastProvider } from './providers/ToastProvider' +import { AuthSyncProvider } from './providers/AuthSyncProvider' function AppShell() { const activeView = useAppStore((s) => s.activeView) const { token, isAdmin } = useAuth() - const { events } = useSSE(token) + const { events } = useSSE() const beta = useNetworkConfigStore((s) => s.config?.beta ?? false) const renderView = () => { switch (activeView) { case 'dashboard': - return + return case 'vault': - return + return case 'herald': - return isAdmin ? : + return isAdmin ? : case 'squad': - return isAdmin ? : + return isAdmin ? : case 'chat': return (
@@ -44,7 +46,7 @@ function AppShell() {
) default: - return + return } } @@ -105,7 +107,11 @@ export default function App() { - + + + + + diff --git a/app/src/api/__tests__/auth.test.ts b/app/src/api/__tests__/auth.test.ts new file mode 100644 index 0000000..102d516 --- /dev/null +++ b/app/src/api/__tests__/auth.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { requestNonce, verifySignature } from '../auth' + +describe('auth api client', () => { + const originalFetch = global.fetch + + beforeEach(() => { + global.fetch = vi.fn() as unknown as typeof fetch + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + describe('requestNonce', () => { + it('POSTs to /api/auth/nonce with wallet and returns nonce + message', async () => { + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => ({ nonce: 'abc', message: 'sign this' }), + }) + + const result = await requestNonce('walletA') + + expect(global.fetch).toHaveBeenCalledWith( + '/api/auth/nonce', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ wallet: 'walletA' }), + headers: expect.objectContaining({ 'Content-Type': 'application/json' }), + }), + ) + expect(result).toEqual({ nonce: 'abc', message: 'sign this' }) + }) + }) + + describe('verifySignature', () => { + it('returns token, isAdmin, and expiresIn from server response', async () => { + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => ({ token: 'tok', isAdmin: false, expiresIn: '24h' }), + }) + + const result = await verifySignature('walletA', 'nonce', 'sig') + + expect(result).toEqual({ token: 'tok', isAdmin: false, expiresIn: '24h' }) + }) + + it('preserves isAdmin true from server response', async () => { + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => ({ token: 'tok', isAdmin: true, expiresIn: '1h' }), + }) + + const result = await verifySignature('walletA', 'nonce', 'sig') + + expect(result.isAdmin).toBe(true) + expect(result.expiresIn).toBe('1h') + }) + + it('throws on non-OK response with structured error', async () => { + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: 'invalid signature' }), + }) + + await expect(verifySignature('walletA', 'nonce', 'sig')).rejects.toThrow(/invalid signature/) + }) + }) +}) diff --git a/app/src/api/__tests__/client.test.ts b/app/src/api/__tests__/client.test.ts new file mode 100644 index 0000000..30d4563 --- /dev/null +++ b/app/src/api/__tests__/client.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { apiFetch, registerAuthInterceptor } from '../client' + +describe('apiFetch', () => { + const originalFetch = global.fetch + + beforeEach(() => { + global.fetch = vi.fn() as unknown as typeof fetch + registerAuthInterceptor(null) + }) + + afterEach(() => { + global.fetch = originalFetch + registerAuthInterceptor(null) + }) + + it('returns parsed json on 2xx', async () => { + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ hello: 'world' }), + }) + const result = await apiFetch<{ hello: string }>('/test') + expect(result).toEqual({ hello: 'world' }) + }) + + it('attaches Authorization header when token option provided', async () => { + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + }) + await apiFetch('/test', { token: 'abc' }) + const callArgs = (global.fetch as unknown as ReturnType).mock.calls[0][1] + expect(callArgs.headers).toMatchObject({ Authorization: 'Bearer abc' }) + }) + + it('calls registered interceptor on 401', async () => { + const onUnauth = vi.fn() + registerAuthInterceptor(onUnauth) + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: { code: 'EXPIRED', message: 'expired' } }), + }) + + await expect(apiFetch('/test')).rejects.toThrow(/expired/) + expect(onUnauth).toHaveBeenCalledOnce() + }) + + it('does not call interceptor on non-401 errors', async () => { + const onUnauth = vi.fn() + registerAuthInterceptor(onUnauth) + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ error: 'server error' }), + }) + + await expect(apiFetch('/test')).rejects.toThrow(/server error/) + expect(onUnauth).not.toHaveBeenCalled() + }) + + it('does not call interceptor on 2xx', async () => { + const onUnauth = vi.fn() + registerAuthInterceptor(onUnauth) + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ok: true }), + }) + await apiFetch('/test') + expect(onUnauth).not.toHaveBeenCalled() + }) + + it('still throws on 401 even if interceptor throws', async () => { + registerAuthInterceptor(() => { + throw new Error('handler crashed') + }) + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: 'expired' }), + }) + + await expect(apiFetch('/test')).rejects.toThrow(/expired/) + }) + + it('parses legacy string error shape on non-2xx', async () => { + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ error: 'bad request' }), + }) + await expect(apiFetch('/test')).rejects.toThrow(/bad request/) + }) + + it('parses structured error envelope on non-2xx', async () => { + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ error: { code: 'X', message: 'detail' } }), + }) + await expect(apiFetch('/test')).rejects.toThrow(/detail/) + }) + + it('falls back to status-based message when no body', async () => { + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 503, + json: async () => { + throw new Error('not json') + }, + }) + await expect(apiFetch('/test')).rejects.toThrow(/503/) + }) + + it('returns undefined for 204 No Content without parsing body', async () => { + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 204, + headers: new Headers(), + json: async () => { + throw new Error('should not be called') + }, + }) + const result = await apiFetch('/test', { method: 'POST' }) + expect(result).toBeUndefined() + }) + + it('returns undefined for content-length: 0 without parsing body', async () => { + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-length': '0' }), + json: async () => { + throw new Error('should not be called') + }, + }) + const result = await apiFetch('/test') + expect(result).toBeUndefined() + }) +}) diff --git a/app/src/api/__tests__/refresh.test.ts b/app/src/api/__tests__/refresh.test.ts new file mode 100644 index 0000000..e163999 --- /dev/null +++ b/app/src/api/__tests__/refresh.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { refreshToken } from '../refresh' + +describe('refreshToken', () => { + const originalFetch = global.fetch + + beforeEach(() => { + global.fetch = vi.fn() as unknown as typeof fetch + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + it('POSTs to /api/auth/refresh with Bearer token and returns new token', async () => { + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => ({ token: 'newtok', expiresIn: '24h' }), + }) + + const result = await refreshToken('oldtok') + + expect(global.fetch).toHaveBeenCalledWith( + '/api/auth/refresh', + expect.objectContaining({ + method: 'POST', + headers: { Authorization: 'Bearer oldtok' }, + }), + ) + expect(result).toEqual({ token: 'newtok', expiresIn: '24h' }) + }) + + it('returns null on 425 (too early)', async () => { + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 425, + json: async () => ({ error: { code: 'TOO_EARLY' } }), + }) + + const result = await refreshToken('oldtok') + expect(result).toBeNull() + }) + + it('returns null on 404 (endpoint not deployed yet)', async () => { + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}), + }) + + const result = await refreshToken('oldtok') + expect(result).toBeNull() + }) + + it('throws on 401 with structured error message', async () => { + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: { code: 'INVALID_TOKEN', message: 'Token invalid or expired' } }), + }) + + await expect(refreshToken('oldtok')).rejects.toThrow(/Token invalid or expired/) + }) + + it('throws on 401 with legacy string error', async () => { + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: 'unauthorized' }), + }) + + await expect(refreshToken('oldtok')).rejects.toThrow(/unauthorized/) + }) + + it('throws on 401 with no error body (default message)', async () => { + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({}), + }) + + await expect(refreshToken('oldtok')).rejects.toThrow(/full re-sign required/) + }) + + it('throws on 5xx with status in message', async () => { + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 503, + json: async () => ({}), + }) + + await expect(refreshToken('oldtok')).rejects.toThrow(/503/) + }) +}) diff --git a/app/src/api/__tests__/sse.test.ts b/app/src/api/__tests__/sse.test.ts new file mode 100644 index 0000000..77a1132 --- /dev/null +++ b/app/src/api/__tests__/sse.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest' +import { pickSseUrl } from '../sse' + +const TOKEN = 'jwt-token-with/special=chars' +const TICKET = 'ticket-abc' +const BASE = 'http://localhost:3000' + +describe('pickSseUrl', () => { + it('uses the ticket query param when ticket is present (any environment)', () => { + const url = pickSseUrl(TOKEN, TICKET, BASE, false) + expect(url).toBe(`${BASE}/api/stream?ticket=${encodeURIComponent(TICKET)}`) + }) + + it('uses the ticket even when isDev is true (ticket always wins)', () => { + const url = pickSseUrl(TOKEN, TICKET, BASE, true) + expect(url).toBe(`${BASE}/api/stream?ticket=${encodeURIComponent(TICKET)}`) + }) + + it('falls back to ?token= URL in DEV when ticket exchange returns null', () => { + const url = pickSseUrl(TOKEN, null, BASE, true) + expect(url).toBe(`${BASE}/api/stream?token=${encodeURIComponent(TOKEN)}`) + }) + + it('throws in production when ticket is null', () => { + expect(() => pickSseUrl(TOKEN, null, BASE, false)).toThrow( + /JWT-in-URL fallback is disabled in production/i, + ) + }) + + it('encodes special characters in the ticket', () => { + const url = pickSseUrl(TOKEN, 'a/b=c&d', BASE, false) + expect(url).toBe(`${BASE}/api/stream?ticket=${encodeURIComponent('a/b=c&d')}`) + }) +}) diff --git a/app/src/api/auth.ts b/app/src/api/auth.ts index 2e2bd0f..d4623cf 100644 --- a/app/src/api/auth.ts +++ b/app/src/api/auth.ts @@ -1,16 +1,41 @@ import { apiFetch } from './client' -export async function requestNonce(wallet: string): Promise<{ nonce: string; message: string }> { +export interface NonceResponse { + nonce: string + message: string +} + +export interface VerifyResponse { + token: string + expiresIn: string + isAdmin: boolean +} + +export async function requestNonce(wallet: string): Promise { return apiFetch('/api/auth/nonce', { method: 'POST', body: JSON.stringify({ wallet }) }) } +export interface VerifyOptions { + /** + * Base64-encoded bytes of the message the wallet actually signed. + * Required for the SIWS path (the wallet signs a CAIP-122-style structured + * message that the server cannot reconstruct from `nonce` alone). When + * omitted, the server reconstructs its own message from `nonce` (legacy + * signMessage path). + */ + signedMessage?: string +} + export async function verifySignature( wallet: string, nonce: string, - signature: string -): Promise<{ token: string; expiresIn: string; isAdmin: boolean }> { + signature: string, + options: VerifyOptions = {} +): Promise { + const body: Record = { wallet, nonce, signature } + if (options.signedMessage) body.signedMessage = options.signedMessage return apiFetch('/api/auth/verify', { method: 'POST', - body: JSON.stringify({ wallet, nonce, signature }), + body: JSON.stringify(body), }) } diff --git a/app/src/api/client.ts b/app/src/api/client.ts index ffb619b..116cf66 100644 --- a/app/src/api/client.ts +++ b/app/src/api/client.ts @@ -1,5 +1,19 @@ const BASE = import.meta.env.VITE_API_URL ?? '' +type UnauthHandler = () => void + +let authInterceptor: UnauthHandler | null = null + +/** + * Register a single global handler invoked whenever apiFetch receives 401. + * Pass `null` to clear. AuthSyncProvider wires this up on mount and tears + * down on unmount, so callers across the app get a consistent re-auth UX + * (clearAuth + toast + Sign in CTA) without each fetch site reinventing it. + */ +export function registerAuthInterceptor(handler: UnauthHandler | null) { + authInterceptor = handler +} + export async function apiFetch( path: string, options?: RequestInit & { token?: string } @@ -13,9 +27,37 @@ export async function apiFetch( ...fetchOpts, headers: { ...headers, ...(fetchOpts.headers as Record) }, }) + if (res.status === 401) { + if (authInterceptor) { + try { + authInterceptor() + } catch { + // Never let an interceptor crash propagate up — auth-loss UX is + // best-effort. The original request still throws below. + } + } + const body = await res.json().catch(() => ({})) + const err = (body as { error?: { message?: string } | string }).error + if (typeof err === 'string') throw new Error(err) + if (err && typeof err === 'object' && typeof err.message === 'string') { + throw new Error(err.message) + } + throw new Error('Authentication required') + } if (!res.ok) { const body = await res.json().catch(() => ({})) - throw new Error((body as { error?: string }).error ?? `API error ${res.status}`) + const err = (body as { error?: { message?: string } | string }).error + if (typeof err === 'string') throw new Error(err) + if (err && typeof err === 'object' && typeof err.message === 'string') { + throw new Error(err.message) + } + throw new Error(`API error ${res.status}`) + } + // 204 No Content / explicitly empty bodies — there is nothing to parse and + // res.json() on an empty stream throws. Endpoints like promise-gate + // resolve/reject return 204 on success; callers ignore the return value. + if (res.status === 204 || res.headers?.get('content-length') === '0') { + return undefined as T } return res.json() as Promise } diff --git a/app/src/api/refresh.ts b/app/src/api/refresh.ts new file mode 100644 index 0000000..d4b08cb --- /dev/null +++ b/app/src/api/refresh.ts @@ -0,0 +1,33 @@ +const BASE = import.meta.env.VITE_API_URL ?? '' + +export interface RefreshResponse { + token: string + expiresIn: string +} + +/** + * POST /api/auth/refresh. + * Returns { token, expiresIn } if refresh succeeded. + * Returns null if too early (server returns 425) or endpoint not deployed (404). + * Throws on 401 / 5xx / network errors. + */ +export async function refreshToken(currentToken: string): Promise { + const res = await fetch(`${BASE}/api/auth/refresh`, { + method: 'POST', + headers: { Authorization: `Bearer ${currentToken}` }, + }) + + if (res.ok) return res.json() + if (res.status === 425) return null + if (res.status === 404) return null + if (res.status === 401) { + const err = await res.json().catch(() => ({})) + const message = (err as { error?: { message?: string } | string }).error + if (typeof message === 'string') throw new Error(message) + if (message && typeof message === 'object' && typeof message.message === 'string') { + throw new Error(message.message) + } + throw new Error('Token invalid; full re-sign required') + } + throw new Error(`Refresh failed: ${res.status}`) +} diff --git a/app/src/api/sse.ts b/app/src/api/sse.ts index 53aa308..1209ffc 100644 --- a/app/src/api/sse.ts +++ b/app/src/api/sse.ts @@ -4,7 +4,7 @@ const API_URL = import.meta.env.VITE_API_URL ?? '' /** * Exchange a JWT for a short-lived, one-time SSE ticket. - * Falls back to null if the endpoint is unavailable (legacy server). + * Returns null if the endpoint is unavailable (legacy server) or rejects. */ async function fetchSseTicket(jwt: string): Promise { try { @@ -24,20 +24,32 @@ async function fetchSseTicket(jwt: string): Promise { } /** - * Create an SSE EventSource using a short-lived ticket (preferred) - * or falling back to raw JWT query param (legacy). + * Pure URL-picker for the SSE stream. Production must never put the raw + * JWT in a URL — query params leak into browser history, server access + * logs, and Referer headers. DEV keeps the JWT-in-URL fallback as a + * convenience for local development against an old server build that + * doesn't expose /api/auth/sse-ticket yet. */ +export function pickSseUrl( + token: string, + ticket: string | null, + baseUrl: string, + isDev: boolean, +): string { + if (ticket) return `${baseUrl}/api/stream?ticket=${encodeURIComponent(ticket)}` + if (isDev) return `${baseUrl}/api/stream?token=${encodeURIComponent(token)}` + throw new Error( + 'SSE ticket exchange failed; JWT-in-URL fallback is disabled in production builds', + ) +} + export async function connectSSE( token: string, onEvent: SSEHandler, - onError?: (err: Event) => void + onError?: (err: Event) => void, ): Promise { - // Try ticket exchange first — keeps JWT out of URLs const ticket = await fetchSseTicket(token) - - const url = ticket - ? `${API_URL}/api/stream?ticket=${encodeURIComponent(ticket)}` - : `${API_URL}/api/stream?token=${encodeURIComponent(token)}` + const url = pickSseUrl(token, ticket, API_URL, import.meta.env.DEV === true) const source = new EventSource(url) source.addEventListener('activity', onEvent) diff --git a/app/src/components/BottomNav.tsx b/app/src/components/BottomNav.tsx index fafe2f8..c56a566 100644 --- a/app/src/components/BottomNav.tsx +++ b/app/src/components/BottomNav.tsx @@ -1,5 +1,4 @@ import { useState } from 'react' -import { useWallet } from '@solana/wallet-adapter-react' import { ChartBar, Vault, @@ -10,7 +9,8 @@ import { SignOut, } from '@phosphor-icons/react' import { useAppStore, type View } from '../stores/app' -import { useIsAdmin } from '../hooks/useIsAdmin' +import { useAuthState } from '../hooks/useAuthState' +import { useToast } from '../providers/ToastProvider' interface TabDef { id: View @@ -27,10 +27,16 @@ const TABS: TabDef[] = [ export default function BottomNav() { const activeView = useAppStore((s) => s.activeView) const setActiveView = useAppStore((s) => s.setActiveView) - const isAdmin = useIsAdmin() - const { disconnect } = useWallet() + const { isAdmin, disconnect } = useAuthState() + const { show: showToast } = useToast() const [moreOpen, setMoreOpen] = useState(false) + const handleDisconnect = async () => { + setMoreOpen(false) + await disconnect() + showToast({ message: 'Disconnected', kind: 'info', durationMs: 3000 }) + } + return ( <>