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 (
<>