Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
840c409
feat(app): add JWT client-side decode helper
rz1989s May 6, 2026
80fa90c
refactor(app): strip semicolons + add expiry-boundary test for jwt
rz1989s May 6, 2026
cae0c36
feat(app): expose VerifyResponse interface + tests for auth client
rz1989s May 6, 2026
e7712e5
feat(app): add /api/auth/refresh client wrapper
rz1989s May 6, 2026
bc086a9
feat(app): add ToastProvider + Toast component
rz1989s May 6, 2026
0b12d62
feat(app): introduce AuthSyncProvider with state machine
rz1989s May 6, 2026
b960b5e
feat(app): wire authenticate() with signMessage path
rz1989s May 6, 2026
50ea00a
feat(app): add SIWS-then-signMessage path to AuthSyncProvider
rz1989s May 6, 2026
2f2ea2c
feat(app): disconnect cleanup + auto-clear on external disconnect
rz1989s May 6, 2026
7ba2481
feat(app): preemptive JWT refresh + expiry-driven cleanup
rz1989s May 6, 2026
b2bfbca
feat(app): global 401 interceptor in apiFetch + toast on session expiry
rz1989s May 6, 2026
d978c47
feat(app): WalletDropdown component (Copy / Re-sign / Disconnect)
rz1989s May 6, 2026
0c8e452
style(app): strip semicolons from jwt.ts (catch-up to 80fa90c)
rz1989s May 6, 2026
b5f9ed4
feat(app): Header consumes useAuthState + WalletDropdown
rz1989s May 6, 2026
d82e579
refactor(app): BottomNav uses useAuthState().disconnect
rz1989s May 6, 2026
d5dc4aa
fix(app): chat surfaces errors as toast, not inline message
rz1989s May 7, 2026
8596165
refactor(app): components consume useAuthState + apiFetch end-to-end
rz1989s May 7, 2026
a5bcda3
refactor(app): useAuth becomes a thin re-export of AuthSyncProvider c…
rz1989s May 7, 2026
aee3682
fix(app): block JWT-in-URL SSE fallback in production builds
rz1989s May 7, 2026
e9ddce4
fix(app): don't auto-clear auth on initial mount before autoConnect r…
rz1989s May 7, 2026
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
20 changes: 13 additions & 7 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,31 +20,33 @@ 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 <DashboardView events={events} token={token} />
return <DashboardView events={events} />
case 'vault':
return <VaultView token={token} />
return <VaultView />
case 'herald':
return isAdmin ? <HeraldView token={token} /> : <DashboardView events={events} token={token} />
return isAdmin ? <HeraldView token={token} /> : <DashboardView events={events} />
case 'squad':
return isAdmin ? <SquadView token={token} /> : <DashboardView events={events} token={token} />
return isAdmin ? <SquadView token={token} /> : <DashboardView events={events} />
case 'chat':
return (
<div className="lg:hidden h-full">
<ChatSidebar fullScreen />
</div>
)
default:
return <DashboardView events={events} token={token} />
return <DashboardView events={events} />
}
}

Expand Down Expand Up @@ -105,7 +107,11 @@ export default function App() {
<ConnectionProvider endpoint={config.publicRpcUrl}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>
<AppShell />
<ToastProvider>
<AuthSyncProvider>
<AppShell />
</AuthSyncProvider>
</ToastProvider>
</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
Expand Down
70 changes: 70 additions & 0 deletions app/src/api/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ error: 'invalid signature' }),
})

await expect(verifySignature('walletA', 'nonce', 'sig')).rejects.toThrow(/invalid signature/)
})
})
})
143 changes: 143 additions & 0 deletions app/src/api/__tests__/client.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({}),
})
await apiFetch('/test', { token: 'abc' })
const callArgs = (global.fetch as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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()
})
})
94 changes: 94 additions & 0 deletions app/src/api/__tests__/refresh.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 503,
json: async () => ({}),
})

await expect(refreshToken('oldtok')).rejects.toThrow(/503/)
})
})
34 changes: 34 additions & 0 deletions app/src/api/__tests__/sse.test.ts
Original file line number Diff line number Diff line change
@@ -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')}`)
})
})
Loading
Loading