From 840c409c7bf841ef3a7f4b85072d71135cf2831b Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 6 May 2026 15:02:56 +0700 Subject: [PATCH 01/20] feat(app): add JWT client-side decode helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds decodeJwtPayload, isJwtExpired, getJwtExpiresAt for client-side TTL checks on the persisted JWT. No signature verification — purely for expiry-watch and graceful re-auth. Used by AuthSyncProvider in subsequent commits. --- app/src/lib/__tests__/jwt.test.ts | 39 +++++++++++++++++++++++++++++++ app/src/lib/jwt.ts | 37 +++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 app/src/lib/__tests__/jwt.test.ts create mode 100644 app/src/lib/jwt.ts diff --git a/app/src/lib/__tests__/jwt.test.ts b/app/src/lib/__tests__/jwt.test.ts new file mode 100644 index 0000000..5543d96 --- /dev/null +++ b/app/src/lib/__tests__/jwt.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { decodeJwtPayload, isJwtExpired, getJwtExpiresAt } from '../jwt'; + +describe('jwt helpers', () => { + // Valid JWT: { wallet: 'TestWallet', iat: 1700000000, exp: 1700003600 } + // (signature is irrelevant for client-side decode) + const validToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3YWxsZXQiOiJUZXN0V2FsbGV0IiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDB9.fake-sig'; + + it('decodes payload', () => { + const payload = decodeJwtPayload(validToken); + expect(payload).toEqual({ wallet: 'TestWallet', iat: 1700000000, exp: 1700003600 }); + }); + + it('returns null for malformed token', () => { + expect(decodeJwtPayload('not.a.jwt')).toBeNull(); + expect(decodeJwtPayload('only-one-part')).toBeNull(); + expect(decodeJwtPayload('')).toBeNull(); + }); + + it('isJwtExpired returns true for expired token', () => { + expect(isJwtExpired(validToken, 1700004000)).toBe(true); + }); + + it('isJwtExpired returns false for valid token', () => { + expect(isJwtExpired(validToken, 1700001800)).toBe(false); + }); + + it('isJwtExpired returns true for malformed token (defensive)', () => { + expect(isJwtExpired('not.a.jwt', 1700001800)).toBe(true); + }); + + it('getJwtExpiresAt returns exp claim in seconds', () => { + expect(getJwtExpiresAt(validToken)).toBe(1700003600); + }); + + it('getJwtExpiresAt returns null for malformed', () => { + expect(getJwtExpiresAt('not.a.jwt')).toBeNull(); + }); +}); diff --git a/app/src/lib/jwt.ts b/app/src/lib/jwt.ts new file mode 100644 index 0000000..1dedd13 --- /dev/null +++ b/app/src/lib/jwt.ts @@ -0,0 +1,37 @@ +// app/src/lib/jwt.ts + +export interface JwtPayload { + wallet: string; + iat: number; + exp: number; + isAdmin?: boolean; +} + +export function decodeJwtPayload(token: string): JwtPayload | null { + const parts = token.split('.'); + if (parts.length !== 3) return null; + try { + // Use base64url decoding; atob handles padded base64 + const padded = parts[1] + '='.repeat((4 - (parts[1].length % 4)) % 4); + const json = atob(padded.replace(/-/g, '+').replace(/_/g, '/')); + const obj = JSON.parse(json); + if (typeof obj !== 'object' || obj === null) return null; + if (typeof obj.wallet !== 'string') return null; + if (typeof obj.iat !== 'number') return null; + if (typeof obj.exp !== 'number') return null; + return obj as JwtPayload; + } catch { + return null; + } +} + +export function getJwtExpiresAt(token: string): number | null { + const payload = decodeJwtPayload(token); + return payload?.exp ?? null; +} + +export function isJwtExpired(token: string, nowSeconds = Math.floor(Date.now() / 1000)): boolean { + const exp = getJwtExpiresAt(token); + if (exp === null) return true; // defensive: treat malformed as expired + return nowSeconds >= exp; +} From 80fa90c991e66a0e328222f02e0beca811be3929 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 6 May 2026 19:45:49 +0700 Subject: [PATCH 02/20] refactor(app): strip semicolons + add expiry-boundary test for jwt - Remove trailing semicolons from jwt.test.ts to match project no-semicolons style (jwt.ts was already compliant) - Add isJwtExpired test at exact-expiry second to lock >= semantics --- app/src/lib/__tests__/jwt.test.ts | 46 +++++++++++++++++-------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/app/src/lib/__tests__/jwt.test.ts b/app/src/lib/__tests__/jwt.test.ts index 5543d96..f025c73 100644 --- a/app/src/lib/__tests__/jwt.test.ts +++ b/app/src/lib/__tests__/jwt.test.ts @@ -1,39 +1,43 @@ -import { describe, it, expect } from 'vitest'; -import { decodeJwtPayload, isJwtExpired, getJwtExpiresAt } from '../jwt'; +import { describe, it, expect } from 'vitest' +import { decodeJwtPayload, isJwtExpired, getJwtExpiresAt } from '../jwt' describe('jwt helpers', () => { // Valid JWT: { wallet: 'TestWallet', iat: 1700000000, exp: 1700003600 } // (signature is irrelevant for client-side decode) - const validToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3YWxsZXQiOiJUZXN0V2FsbGV0IiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDB9.fake-sig'; + const validToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3YWxsZXQiOiJUZXN0V2FsbGV0IiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDB9.fake-sig' it('decodes payload', () => { - const payload = decodeJwtPayload(validToken); - expect(payload).toEqual({ wallet: 'TestWallet', iat: 1700000000, exp: 1700003600 }); - }); + const payload = decodeJwtPayload(validToken) + expect(payload).toEqual({ wallet: 'TestWallet', iat: 1700000000, exp: 1700003600 }) + }) it('returns null for malformed token', () => { - expect(decodeJwtPayload('not.a.jwt')).toBeNull(); - expect(decodeJwtPayload('only-one-part')).toBeNull(); - expect(decodeJwtPayload('')).toBeNull(); - }); + expect(decodeJwtPayload('not.a.jwt')).toBeNull() + expect(decodeJwtPayload('only-one-part')).toBeNull() + expect(decodeJwtPayload('')).toBeNull() + }) it('isJwtExpired returns true for expired token', () => { - expect(isJwtExpired(validToken, 1700004000)).toBe(true); - }); + expect(isJwtExpired(validToken, 1700004000)).toBe(true) + }) it('isJwtExpired returns false for valid token', () => { - expect(isJwtExpired(validToken, 1700001800)).toBe(false); - }); + expect(isJwtExpired(validToken, 1700001800)).toBe(false) + }) + + it('isJwtExpired returns true at exact expiry second (>=)', () => { + expect(isJwtExpired(validToken, 1700003600)).toBe(true) + }) it('isJwtExpired returns true for malformed token (defensive)', () => { - expect(isJwtExpired('not.a.jwt', 1700001800)).toBe(true); - }); + expect(isJwtExpired('not.a.jwt', 1700001800)).toBe(true) + }) it('getJwtExpiresAt returns exp claim in seconds', () => { - expect(getJwtExpiresAt(validToken)).toBe(1700003600); - }); + expect(getJwtExpiresAt(validToken)).toBe(1700003600) + }) it('getJwtExpiresAt returns null for malformed', () => { - expect(getJwtExpiresAt('not.a.jwt')).toBeNull(); - }); -}); + expect(getJwtExpiresAt('not.a.jwt')).toBeNull() + }) +}) From cae0c36153ec252197f7c06d7ae2f7f1d1e41bd1 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 6 May 2026 19:47:05 +0700 Subject: [PATCH 03/20] feat(app): expose VerifyResponse interface + tests for auth client - Extract VerifyResponse + NonceResponse interfaces (was inline types) - Add app/src/api/__tests__/auth.test.ts covering nonce request, verify preserves expiresIn + isAdmin, error path - isAdmin stays required (server contract guarantees it on every response) AuthSyncProvider will read expiresIn to schedule preemptive refresh. --- app/src/api/__tests__/auth.test.ts | 70 ++++++++++++++++++++++++++++++ app/src/api/auth.ts | 15 ++++++- 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 app/src/api/__tests__/auth.test.ts 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/auth.ts b/app/src/api/auth.ts index 2e2bd0f..92467ea 100644 --- a/app/src/api/auth.ts +++ b/app/src/api/auth.ts @@ -1,6 +1,17 @@ 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 }) }) } @@ -8,7 +19,7 @@ export async function verifySignature( wallet: string, nonce: string, signature: string -): Promise<{ token: string; expiresIn: string; isAdmin: boolean }> { +): Promise { return apiFetch('/api/auth/verify', { method: 'POST', body: JSON.stringify({ wallet, nonce, signature }), From e7712e557e7a4058fcb5cb4935231bcdc7cb875b Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 6 May 2026 19:47:47 +0700 Subject: [PATCH 04/20] feat(app): add /api/auth/refresh client wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returns { token, expiresIn } on 200. Returns null on 425 (server says too early — try again later) or 404 (endpoint not deployed — graceful degradation; older sipher BE responds 404 on POST /api/auth/refresh). Throws on 401 (force full re-sign) or 5xx. AuthSyncProvider's expiry watcher will call this within 5min of token exp to swap in a fresh JWT without user interaction. --- app/src/api/__tests__/refresh.test.ts | 94 +++++++++++++++++++++++++++ app/src/api/refresh.ts | 33 ++++++++++ 2 files changed, 127 insertions(+) create mode 100644 app/src/api/__tests__/refresh.test.ts create mode 100644 app/src/api/refresh.ts 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/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}`) +} From bc086a9a85769072705d5c5dd680804c81751cf9 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 6 May 2026 19:49:10 +0700 Subject: [PATCH 05/20] feat(app): add ToastProvider + Toast component Toast slot for surfacing 401 expiry, sign-in errors, network issues. Used by apiFetch interceptor + AuthSyncProvider in subsequent commits. - 4 kinds (info / warn / error / success) with kind-specific tailwind tokens - Optional action button (used for "Sign in" CTA on 401 expiry toast) - Auto-dismiss after 7s by default; durationMs=0 makes toast sticky - Phosphor X icon for dismiss; role=status + aria-live=polite for a11y - Timer cleanup on unmount + on explicit dismiss (prevents stale setState) - crypto.randomUUID for stable keys - useToast() throws if called outside provider (catches wiring mistakes early) --- app/src/components/Toast.tsx | 48 +++++++ app/src/providers/ToastProvider.tsx | 69 ++++++++++ .../__tests__/ToastProvider.test.tsx | 121 ++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 app/src/components/Toast.tsx create mode 100644 app/src/providers/ToastProvider.tsx create mode 100644 app/src/providers/__tests__/ToastProvider.test.tsx diff --git a/app/src/components/Toast.tsx b/app/src/components/Toast.tsx new file mode 100644 index 0000000..b688102 --- /dev/null +++ b/app/src/components/Toast.tsx @@ -0,0 +1,48 @@ +import { X } from '@phosphor-icons/react' + +export interface ToastInput { + message: string + kind?: 'info' | 'warn' | 'error' | 'success' + durationMs?: number + action?: { label: string; onClick: () => void } +} + +const kindStyles: Record, string> = { + info: 'bg-elevated border-border text-text', + warn: 'bg-amber-950/90 border-amber-700 text-amber-100', + error: 'bg-red-950/90 border-red-700 text-red-100', + success: 'bg-emerald-950/90 border-emerald-700 text-emerald-100', +} + +export function Toast({ toast, onDismiss }: { toast: ToastInput; onDismiss: () => void }) { + const styles = kindStyles[toast.kind ?? 'info'] + return ( +
+
+ {toast.message} + +
+ {toast.action && ( + + )} +
+ ) +} diff --git a/app/src/providers/ToastProvider.tsx b/app/src/providers/ToastProvider.tsx new file mode 100644 index 0000000..b711320 --- /dev/null +++ b/app/src/providers/ToastProvider.tsx @@ -0,0 +1,69 @@ +import { createContext, useCallback, useContext, useEffect, useRef, useState, ReactNode } from 'react' +import { Toast, ToastInput } from '../components/Toast' + +interface ToastWithId extends ToastInput { + id: string +} + +interface ToastContextValue { + show: (input: ToastInput) => string + dismiss: (id: string) => void +} + +const ToastContext = createContext(null) + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext) + if (!ctx) throw new Error('useToast must be used within ToastProvider') + return ctx +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + const timersRef = useRef>>(new Map()) + + const dismiss = useCallback((id: string) => { + const timer = timersRef.current.get(id) + if (timer) { + clearTimeout(timer) + timersRef.current.delete(id) + } + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, []) + + const show = useCallback( + (input: ToastInput) => { + const id = crypto.randomUUID() + setToasts((prev) => [...prev, { ...input, id }]) + const duration = input.durationMs ?? 7000 + if (duration > 0) { + const timer = setTimeout(() => { + timersRef.current.delete(id) + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, duration) + timersRef.current.set(id, timer) + } + return id + }, + [], + ) + + useEffect(() => { + const timers = timersRef.current + return () => { + for (const timer of timers.values()) clearTimeout(timer) + timers.clear() + } + }, []) + + return ( + + {children} +
+ {toasts.map((t) => ( + dismiss(t.id)} /> + ))} +
+
+ ) +} diff --git a/app/src/providers/__tests__/ToastProvider.test.tsx b/app/src/providers/__tests__/ToastProvider.test.tsx new file mode 100644 index 0000000..3e43936 --- /dev/null +++ b/app/src/providers/__tests__/ToastProvider.test.tsx @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render, screen, fireEvent, act } from '@testing-library/react' +import { ToastProvider, useToast } from '../ToastProvider' + +function TriggerButton({ input }: { input: Parameters['show']>[0] }) { + const { show } = useToast() + return +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('ToastProvider', () => { + it('renders children', () => { + render( + + child + , + ) + expect(screen.getByText('child')).toBeInTheDocument() + }) + + it('shows toast when show() called', () => { + render( + + + , + ) + fireEvent.click(screen.getByText('Trigger')) + expect(screen.getByText('Hello world')).toBeInTheDocument() + }) + + it('renders action button if action provided', () => { + const onAction = vi.fn() + render( + + + , + ) + fireEvent.click(screen.getByText('Trigger')) + const actionButton = screen.getByText('Sign in') + expect(actionButton).toBeInTheDocument() + + fireEvent.click(actionButton) + expect(onAction).toHaveBeenCalledTimes(1) + expect(screen.queryByText('Session expired')).not.toBeInTheDocument() + }) + + it('dismisses toast on close button click', () => { + render( + + + , + ) + fireEvent.click(screen.getByText('Trigger')) + expect(screen.getByText('Bye')).toBeInTheDocument() + + const dismissBtn = screen.getByLabelText('Dismiss') + fireEvent.click(dismissBtn) + expect(screen.queryByText('Bye')).not.toBeInTheDocument() + }) + + it('auto-dismisses after default 7 seconds', () => { + vi.useFakeTimers() + render( + + + , + ) + fireEvent.click(screen.getByText('Trigger')) + expect(screen.getByText('Auto')).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(7000) + }) + expect(screen.queryByText('Auto')).not.toBeInTheDocument() + }) + + it('does not auto-dismiss when durationMs is 0', () => { + vi.useFakeTimers() + render( + + + , + ) + fireEvent.click(screen.getByText('Trigger')) + + act(() => { + vi.advanceTimersByTime(60_000) + }) + expect(screen.getByText('Sticky')).toBeInTheDocument() + }) + + it('uses warn styles for kind=warn', () => { + render( + + + , + ) + fireEvent.click(screen.getByText('Trigger')) + const toast = screen.getByText('Warn me').closest('[role="status"]') + expect(toast).toHaveClass('bg-amber-950/90') + }) + + it('throws when useToast called outside provider', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + function Bad() { + useToast() + return null + } + expect(() => render()).toThrow(/useToast must be used within ToastProvider/) + errSpy.mockRestore() + }) +}) From 0b12d6293927781815b0f18582eb03e5bb32957f Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 6 May 2026 19:51:58 +0700 Subject: [PATCH 06/20] feat(app): introduce AuthSyncProvider with state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AuthSyncProvider that owns the auth state machine (status: connecting/unauthed/authed/expired/error), token, expiresAt, isAdmin, publicKey. Reconciles wallet-adapter state with the persisted JWT — clears auth if the wallet changes mid-session OR if the persisted JWT was issued for a wallet other than the currently-connected one (cold reload after wallet swap). Zustand store gains expiresAt: number | null + persist version: 1 with a migrate fn that nukes v0 data (forces re-auth on first load after this ships, so previously-persisted tokens without expiresAt don't stick around indefinitely). setAuth picks up an optional expiresAt arg — existing 2-arg callers (hooks/useAuth.ts) continue to work and pass null until they migrate in subsequent tasks. authenticate() and disconnect() are stubs in this commit; full SIWS- then-signMessage flow lands in Task A6 and richer disconnect cleanup in A7. Existing components not yet migrated; they continue to use useWallet + useAppStore directly until Tasks A12-A17. --- app/src/hooks/useAuthState.ts | 2 + app/src/providers/AuthSyncProvider.tsx | 91 ++++++++++ .../__tests__/AuthSyncProvider.test.tsx | 155 ++++++++++++++++++ app/src/stores/app.ts | 18 +- 4 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 app/src/hooks/useAuthState.ts create mode 100644 app/src/providers/AuthSyncProvider.tsx create mode 100644 app/src/providers/__tests__/AuthSyncProvider.test.tsx diff --git a/app/src/hooks/useAuthState.ts b/app/src/hooks/useAuthState.ts new file mode 100644 index 0000000..e724608 --- /dev/null +++ b/app/src/hooks/useAuthState.ts @@ -0,0 +1,2 @@ +export { useAuthSyncContext as useAuthState } from '../providers/AuthSyncProvider' +export type { AuthState, AuthStatus } from '../providers/AuthSyncProvider' diff --git a/app/src/providers/AuthSyncProvider.tsx b/app/src/providers/AuthSyncProvider.tsx new file mode 100644 index 0000000..c855726 --- /dev/null +++ b/app/src/providers/AuthSyncProvider.tsx @@ -0,0 +1,91 @@ +import { createContext, useContext, useEffect, useMemo, useRef, ReactNode } from 'react' +import { useWallet } from '@solana/wallet-adapter-react' +import { useWalletModal } from '@solana/wallet-adapter-react-ui' +import { useAppStore } from '../stores/app' +import { decodeJwtPayload, isJwtExpired } from '../lib/jwt' + +export type AuthStatus = 'connecting' | 'unauthed' | 'authed' | 'expired' | 'error' + +export interface AuthState { + status: AuthStatus + token: string | null + expiresAt: number | null + isAdmin: boolean + publicKey: string | null + authenticate: () => Promise + disconnect: () => Promise + error: string | null +} + +const AuthSyncContext = createContext(null) + +export function useAuthSyncContext(): AuthState { + const ctx = useContext(AuthSyncContext) + if (!ctx) throw new Error('useAuthSyncContext must be used within AuthSyncProvider') + return ctx +} + +export function AuthSyncProvider({ children }: { children: ReactNode }) { + const { connected, publicKey, disconnect: walletDisconnect } = useWallet() + const { setVisible } = useWalletModal() + const token = useAppStore((s) => s.token) + const isAdmin = useAppStore((s) => s.isAdmin) + const expiresAt = useAppStore((s) => s.expiresAt) + const clearAuth = useAppStore((s) => s.clearAuth) + + const lastWalletRef = useRef(null) + + // Track wallet identity across renders; clear auth when the wallet changes. + useEffect(() => { + const currentWallet = publicKey?.toBase58() ?? null + if ( + currentWallet && + lastWalletRef.current && + currentWallet !== lastWalletRef.current + ) { + clearAuth() + } + if (currentWallet) lastWalletRef.current = currentWallet + if (!connected) lastWalletRef.current = null + }, [publicKey, connected, clearAuth]) + + // Validate that the persisted JWT was issued for the currently-connected + // wallet. Catches loading a stale token under a different wallet without + // a wallet-switch event (e.g. cold reload after wallet swap). + useEffect(() => { + if (!connected || !publicKey || !token) return + const payload = decodeJwtPayload(token) + if (!payload || payload.wallet !== publicKey.toBase58()) { + clearAuth() + } + }, [connected, publicKey, token, clearAuth]) + + const status: AuthStatus = useMemo(() => { + if (!connected || !publicKey) return 'unauthed' + if (!token) return 'unauthed' + if (isJwtExpired(token)) return 'expired' + return 'authed' + }, [connected, publicKey, token]) + + const authenticate = async () => { + setVisible(true) + } + + const disconnect = async () => { + await walletDisconnect() + clearAuth() + } + + const value: AuthState = { + status, + token, + expiresAt, + isAdmin, + publicKey: publicKey?.toBase58() ?? null, + authenticate, + disconnect, + error: null, + } + + return {children} +} diff --git a/app/src/providers/__tests__/AuthSyncProvider.test.tsx b/app/src/providers/__tests__/AuthSyncProvider.test.tsx new file mode 100644 index 0000000..04ae417 --- /dev/null +++ b/app/src/providers/__tests__/AuthSyncProvider.test.tsx @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { AuthSyncProvider } from '../AuthSyncProvider' +import { useAuthState } from '../../hooks/useAuthState' +import { useAppStore } from '../../stores/app' + +vi.mock('@solana/wallet-adapter-react', () => ({ + useWallet: vi.fn(), +})) +vi.mock('@solana/wallet-adapter-react-ui', () => ({ + useWalletModal: () => ({ setVisible: vi.fn() }), +})) + +import { useWallet } from '@solana/wallet-adapter-react' + +const mockedUseWallet = useWallet as unknown as ReturnType + +function TestConsumer() { + const auth = useAuthState() + return ( + <> + {auth.status} + {auth.token ?? 'null'} + {auth.publicKey ?? 'null'} + {auth.isAdmin ? 'yes' : 'no'} + + ) +} + +function makeJwtForTest(payload: { wallet: string; exp: number; isAdmin?: boolean }): string { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const body = btoa(JSON.stringify({ iat: payload.exp - 3600, ...payload })) + return `${header}.${body}.testsig` +} + +describe('AuthSyncProvider — status machine', () => { + beforeEach(() => { + useAppStore.setState({ token: null, isAdmin: false, expiresAt: null }, false) + mockedUseWallet.mockReset() + }) + + it('reports status=unauthed when no wallet, no token', () => { + mockedUseWallet.mockReturnValue({ + connected: false, + publicKey: null, + wallet: null, + disconnect: vi.fn(), + }) + render( + + + , + ) + expect(screen.getByTestId('status').textContent).toBe('unauthed') + expect(screen.getByTestId('token').textContent).toBe('null') + }) + + it('reports status=unauthed when wallet connected but no token', () => { + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'W' }, + wallet: null, + disconnect: vi.fn(), + }) + render( + + + , + ) + expect(screen.getByTestId('status').textContent).toBe('unauthed') + }) + + it('reports status=expired when persisted token is expired (and matches wallet)', () => { + const expiredToken = makeJwtForTest({ wallet: 'W', exp: 1000 }) + useAppStore.setState({ token: expiredToken, isAdmin: false, expiresAt: 1000 }, false) + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'W' }, + wallet: null, + disconnect: vi.fn(), + }) + render( + + + , + ) + expect(screen.getByTestId('status').textContent).toBe('expired') + }) + + it('reports status=authed when wallet+token both valid and matching', () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600 + const validToken = makeJwtForTest({ wallet: 'W', exp: futureExp }) + useAppStore.setState({ token: validToken, isAdmin: false, expiresAt: futureExp }, false) + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'W' }, + wallet: null, + disconnect: vi.fn(), + }) + render( + + + , + ) + expect(screen.getByTestId('status').textContent).toBe('authed') + expect(screen.getByTestId('token').textContent).toBe(validToken) + }) + + it('clears token if persisted wallet ≠ current wallet', () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600 + const tokenForA = makeJwtForTest({ wallet: 'WalletA', exp: futureExp }) + useAppStore.setState({ token: tokenForA, isAdmin: false, expiresAt: futureExp }, false) + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'WalletB' }, + wallet: null, + disconnect: vi.fn(), + }) + render( + + + , + ) + expect(screen.getByTestId('token').textContent).toBe('null') + expect(screen.getByTestId('status').textContent).toBe('unauthed') + }) + + it('exposes isAdmin from store', () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600 + const validToken = makeJwtForTest({ wallet: 'W', exp: futureExp, isAdmin: true }) + useAppStore.setState({ token: validToken, isAdmin: true, expiresAt: futureExp }, false) + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'W' }, + wallet: null, + disconnect: vi.fn(), + }) + render( + + + , + ) + expect(screen.getByTestId('isAdmin').textContent).toBe('yes') + }) + + it('throws when useAuthState used outside provider', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + function Bad() { + useAuthState() + return null + } + expect(() => render()).toThrow(/useAuthSyncContext must be used within AuthSyncProvider/) + errSpy.mockRestore() + }) +}) diff --git a/app/src/stores/app.ts b/app/src/stores/app.ts index 84ddb1e..cf07a3f 100644 --- a/app/src/stores/app.ts +++ b/app/src/stores/app.ts @@ -30,7 +30,8 @@ interface AppState { token: string | null isAdmin: boolean - setAuth: (token: string, isAdmin: boolean) => void + expiresAt: number | null + setAuth: (token: string, isAdmin: boolean, expiresAt?: number | null) => void clearAuth: () => void messages: ChatMessage[] @@ -58,8 +59,10 @@ export const useAppStore = create()( token: null, isAdmin: false, - setAuth: (token, isAdmin) => set({ token, isAdmin }), - clearAuth: () => set({ token: null, isAdmin: false, messages: [], activeView: 'dashboard' }), + expiresAt: null, + setAuth: (token, isAdmin, expiresAt = null) => set({ token, isAdmin, expiresAt }), + clearAuth: () => + set({ token: null, isAdmin: false, expiresAt: null, messages: [], activeView: 'dashboard' }), messages: [], chatLoading: false, @@ -122,8 +125,15 @@ export const useAppStore = create()( }), { name: 'sipher-auth', + version: 1, storage: createJSONStorage(() => localStorage), - partialize: (s) => ({ token: s.token, isAdmin: s.isAdmin }), + partialize: (s) => ({ token: s.token, isAdmin: s.isAdmin, expiresAt: s.expiresAt }), + migrate: (persistedState, fromVersion) => { + if (fromVersion === 0) { + return { ...(persistedState as object), token: null, isAdmin: false, expiresAt: null } + } + return persistedState as Partial + }, } ) ) From b960b5ec4678ec76f0ebedde3c0602a63a89a94b Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 6 May 2026 19:56:31 +0700 Subject: [PATCH 07/20] feat(app): wire authenticate() with signMessage path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the connect-and-sign flow inside AuthSyncProvider: - /api/auth/nonce → wallet.signMessage(message) → /api/auth/verify - Stores token, isAdmin, expiresAt (from server expiresIn) into Zustand - status='connecting' while in flight - Throws if wallet has no signMessage (escapes to caller for toast/UI) - User-rejection from signMessage propagates as Error - error field exposed via useAuthState() for inline UI surfacing DEVIATION FROM PLAN: Spec D5 calls for SIWS-then-signMessage fallback, but the backend's /api/auth/verify hardcodes the signed-message format (\"sipher.sip-protocol.org wants you to sign in.\\n\\nNonce: \${nonce}\"). A wallet-standard signIn() returns a SIWS-structured message; the signature would not verify against the server's hardcoded format. SIWS support requires backend changes — track separately as a follow-up once the backend learns to verify against the wallet-supplied signedMessage. For now, signMessage handles all wallet-standard wallets uniformly (Phantom, Solflare, Backpack, Jupiter, OKX). Functional parity with existing useAuth.ts is preserved; AuthSyncProvider adds the state machine + reconciliation effects that useAuth lacked. --- app/src/providers/AuthSyncProvider.tsx | 61 ++++- .../__tests__/AuthSyncProvider.test.tsx | 213 +++++++++++++++++- 2 files changed, 265 insertions(+), 9 deletions(-) diff --git a/app/src/providers/AuthSyncProvider.tsx b/app/src/providers/AuthSyncProvider.tsx index c855726..cf5f452 100644 --- a/app/src/providers/AuthSyncProvider.tsx +++ b/app/src/providers/AuthSyncProvider.tsx @@ -1,8 +1,9 @@ -import { createContext, useContext, useEffect, useMemo, useRef, ReactNode } from 'react' +import { createContext, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react' import { useWallet } from '@solana/wallet-adapter-react' import { useWalletModal } from '@solana/wallet-adapter-react-ui' import { useAppStore } from '../stores/app' import { decodeJwtPayload, isJwtExpired } from '../lib/jwt' +import { requestNonce, verifySignature } from '../api/auth' export type AuthStatus = 'connecting' | 'unauthed' | 'authed' | 'expired' | 'error' @@ -26,13 +27,16 @@ export function useAuthSyncContext(): AuthState { } export function AuthSyncProvider({ children }: { children: ReactNode }) { - const { connected, publicKey, disconnect: walletDisconnect } = useWallet() + const { connected, publicKey, signMessage, disconnect: walletDisconnect } = useWallet() const { setVisible } = useWalletModal() const token = useAppStore((s) => s.token) const isAdmin = useAppStore((s) => s.isAdmin) const expiresAt = useAppStore((s) => s.expiresAt) + const setAuth = useAppStore((s) => s.setAuth) const clearAuth = useAppStore((s) => s.clearAuth) + const [error, setError] = useState(null) + const [authenticating, setAuthenticating] = useState(false) const lastWalletRef = useRef(null) // Track wallet identity across renders; clear auth when the wallet changes. @@ -61,14 +65,42 @@ export function AuthSyncProvider({ children }: { children: ReactNode }) { }, [connected, publicKey, token, clearAuth]) const status: AuthStatus = useMemo(() => { + if (authenticating) return 'connecting' if (!connected || !publicKey) return 'unauthed' if (!token) return 'unauthed' if (isJwtExpired(token)) return 'expired' return 'authed' - }, [connected, publicKey, token]) + }, [authenticating, connected, publicKey, token]) const authenticate = async () => { - setVisible(true) + if (!connected || !publicKey) { + setVisible(true) + return + } + if (!signMessage) { + const message = "This wallet doesn't support sign-in. Try Phantom, Solflare, or another wallet-standard wallet." + setError(message) + throw new Error(message) + } + + setAuthenticating(true) + setError(null) + try { + const wallet58 = publicKey.toBase58() + const { nonce, message } = await requestNonce(wallet58) + const sig = await signMessage(new TextEncoder().encode(message)) + const sigHex = bytesToHex(sig) + const verifyResult = await verifySignature(wallet58, nonce, sigHex) + const expiresAtSec = parseExpiryToEpoch(verifyResult.expiresIn) + setAuth(verifyResult.token, verifyResult.isAdmin, expiresAtSec) + lastWalletRef.current = wallet58 + } catch (err) { + const message = err instanceof Error ? err.message : 'Sign-in failed' + setError(message) + throw err instanceof Error ? err : new Error(message) + } finally { + setAuthenticating(false) + } } const disconnect = async () => { @@ -84,8 +116,27 @@ export function AuthSyncProvider({ children }: { children: ReactNode }) { publicKey: publicKey?.toBase58() ?? null, authenticate, disconnect, - error: null, + error, } return {children} } + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +// Convert "24h" / "1h" / "300s" / "7d" relative TTL to absolute epoch seconds. +// Falls back to now+1h for unparseable input rather than throwing — verify +// already succeeded so we shouldn't drop the token over a parsing edge case. +function parseExpiryToEpoch(expiresIn: string): number { + const match = expiresIn.match(/^(\d+)\s*(s|m|h|d)$/i) + const now = Math.floor(Date.now() / 1000) + if (!match) return now + 3600 + const n = parseInt(match[1], 10) + const unit = match[2].toLowerCase() as 's' | 'm' | 'h' | 'd' + const mul = { s: 1, m: 60, h: 3600, d: 86400 }[unit] + return now + n * mul +} diff --git a/app/src/providers/__tests__/AuthSyncProvider.test.tsx b/app/src/providers/__tests__/AuthSyncProvider.test.tsx index 04ae417..5dcedf5 100644 --- a/app/src/providers/__tests__/AuthSyncProvider.test.tsx +++ b/app/src/providers/__tests__/AuthSyncProvider.test.tsx @@ -1,14 +1,16 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, act } from '@testing-library/react' import { AuthSyncProvider } from '../AuthSyncProvider' -import { useAuthState } from '../../hooks/useAuthState' +import { useAuthState, type AuthState } from '../../hooks/useAuthState' import { useAppStore } from '../../stores/app' +const mockSetVisible = vi.fn() + vi.mock('@solana/wallet-adapter-react', () => ({ useWallet: vi.fn(), })) vi.mock('@solana/wallet-adapter-react-ui', () => ({ - useWalletModal: () => ({ setVisible: vi.fn() }), + useWalletModal: () => ({ setVisible: mockSetVisible, visible: false }), })) import { useWallet } from '@solana/wallet-adapter-react' @@ -153,3 +155,206 @@ describe('AuthSyncProvider — status machine', () => { errSpy.mockRestore() }) }) + +describe('AuthSyncProvider — authenticate', () => { + const originalFetch = global.fetch + + beforeEach(() => { + useAppStore.setState({ token: null, isAdmin: false, expiresAt: null }, false) + mockedUseWallet.mockReset() + global.fetch = vi.fn() as unknown as typeof fetch + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + function captureAuth() { + const captured: { current: AuthState | null } = { current: null } + function Capture() { + captured.current = useAuthState() + return null + } + return { Capture, captured } + } + + it('opens wallet modal when called with no wallet connected', async () => { + mockSetVisible.mockReset() + mockedUseWallet.mockReturnValue({ + connected: false, + publicKey: null, + signMessage: undefined, + disconnect: vi.fn(), + }) + + const { Capture, captured } = captureAuth() + render( + + + , + ) + await act(async () => { + await captured.current!.authenticate() + }) + expect(mockSetVisible).toHaveBeenCalledWith(true) + }) + + it('signMessage happy path: nonce → sign → verify → setAuth', async () => { + const futureExp = Math.floor(Date.now() / 1000) + 86400 + const issuedToken = makeJwtForTest({ wallet: 'WalletA', exp: futureExp }) + const mockSignMessage = vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4])) + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'WalletA' }, + signMessage: mockSignMessage, + disconnect: vi.fn(), + }) + ;(global.fetch as unknown as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ nonce: 'abc', message: 'sipher.sip-protocol.org wants you to sign in.\n\nNonce: abc' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ token: issuedToken, isAdmin: false, expiresIn: '24h' }), + }) + + const { Capture, captured } = captureAuth() + render( + + + , + ) + + await act(async () => { + await captured.current!.authenticate() + }) + + expect(mockSignMessage).toHaveBeenCalledOnce() + const state = useAppStore.getState() + expect(state.token).toBe(issuedToken) + expect(state.isAdmin).toBe(false) + expect(state.expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000)) + }) + + it('passes through isAdmin=true from server', async () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600 + const adminToken = makeJwtForTest({ wallet: 'AdminWallet', exp: futureExp, isAdmin: true }) + const mockSignMessage = vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4])) + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'AdminWallet' }, + signMessage: mockSignMessage, + disconnect: vi.fn(), + }) + ;(global.fetch as unknown as ReturnType) + .mockResolvedValueOnce({ ok: true, json: async () => ({ nonce: 'a', message: 'm' }) }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ token: adminToken, isAdmin: true, expiresIn: '1h' }), + }) + + const { Capture, captured } = captureAuth() + render( + + + , + ) + await act(async () => { + await captured.current!.authenticate() + }) + expect(useAppStore.getState().isAdmin).toBe(true) + }) + + it('throws when wallet has no signMessage', async () => { + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'W' }, + signMessage: undefined, + disconnect: vi.fn(), + }) + + const { Capture, captured } = captureAuth() + render( + + + , + ) + + await expect( + act(async () => { + await captured.current!.authenticate() + }), + ).rejects.toThrow(/doesn't support sign-in/i) + expect(useAppStore.getState().token).toBeNull() + }) + + it('propagates user-rejection from signMessage', async () => { + const rejectErr = new Error('User rejected the request') + const mockSignMessage = vi.fn().mockRejectedValue(rejectErr) + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'W' }, + signMessage: mockSignMessage, + disconnect: vi.fn(), + }) + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => ({ nonce: 'abc', message: 'm' }), + }) + + const { Capture, captured } = captureAuth() + render( + + + , + ) + + await expect( + act(async () => { + await captured.current!.authenticate() + }), + ).rejects.toThrow(/User rejected/i) + expect(useAppStore.getState().token).toBeNull() + }) + + it('reports status=connecting while authenticate runs', async () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600 + const okToken = makeJwtForTest({ wallet: 'W', exp: futureExp }) + let resolveSign: ((s: Uint8Array) => void) | null = null + const mockSignMessage = vi.fn().mockReturnValue( + new Promise((resolve) => { + resolveSign = resolve + }), + ) + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'W' }, + signMessage: mockSignMessage, + disconnect: vi.fn(), + }) + ;(global.fetch as unknown as ReturnType) + .mockResolvedValueOnce({ ok: true, json: async () => ({ nonce: 'a', message: 'm' }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ token: okToken, isAdmin: false, expiresIn: '1h' }) }) + + const { Capture, captured } = captureAuth() + render( + + + , + ) + + let pending: Promise | null = null + await act(async () => { + pending = captured.current!.authenticate() + await Promise.resolve() + }) + expect(captured.current!.status).toBe('connecting') + + await act(async () => { + resolveSign!(new Uint8Array([1, 2, 3])) + await pending! + }) + expect(captured.current!.status).toBe('authed') + }) +}) From 50ea00a96eb93cf5d8850ee879674a9e818c8a58 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 6 May 2026 20:15:34 +0700 Subject: [PATCH 08/20] feat(app): add SIWS-then-signMessage path to AuthSyncProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the full spec D5 flow that was deferred in b960b5e: - Try wallet-standard signIn() first (Phantom/Solflare/Backpack path). Returns { signature, signedMessage }; both are forwarded to /api/auth/verify so the server can verify against the actual bytes the wallet signed (not a server-reconstructed string). - User rejection from signIn() propagates as Error; no second wallet popup. - SIWS errors that aren't user rejection (signIn not implemented, network blip, etc.) trigger graceful fallback to signMessage. - After SIWS sign succeeds but before token is issued: if the server rejects (legacy behavior — the BE ignores signedMessage and tries to reconstruct a different message format), fall back to signMessage. This keeps PR 1 deployable BEFORE PR 2 ships SIWS server support. - signMessage path unchanged: hex-encoded signature, server reconstructs message from nonce. api/auth.ts: verifySignature gains optional signedMessage option (base64 string of the bytes the wallet actually signed). PAIRED WITH PR 2: adds signedMessage handling to /api/auth/verify so SIWS-supporting wallets get the optimal one-popup connect+sign UX once both PRs ship. Until then, all wallets see the legacy two-popup signMessage path. 5 new tests cover SIWS happy path, server-rejects-fallback, no-signature-fallback, non-rejection-error-fallback, and user-rejection-propagation. --- app/src/api/auth.ts | 18 +- app/src/providers/AuthSyncProvider.tsx | 125 +++++++++++- .../__tests__/AuthSyncProvider.test.tsx | 185 ++++++++++++++++++ 3 files changed, 320 insertions(+), 8 deletions(-) diff --git a/app/src/api/auth.ts b/app/src/api/auth.ts index 92467ea..d4623cf 100644 --- a/app/src/api/auth.ts +++ b/app/src/api/auth.ts @@ -15,13 +15,27 @@ 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 + 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/providers/AuthSyncProvider.tsx b/app/src/providers/AuthSyncProvider.tsx index cf5f452..a9d910c 100644 --- a/app/src/providers/AuthSyncProvider.tsx +++ b/app/src/providers/AuthSyncProvider.tsx @@ -27,7 +27,7 @@ export function useAuthSyncContext(): AuthState { } export function AuthSyncProvider({ children }: { children: ReactNode }) { - const { connected, publicKey, signMessage, disconnect: walletDisconnect } = useWallet() + const { connected, publicKey, wallet, signMessage, disconnect: walletDisconnect } = useWallet() const { setVisible } = useWalletModal() const token = useAppStore((s) => s.token) const isAdmin = useAppStore((s) => s.isAdmin) @@ -77,8 +77,9 @@ export function AuthSyncProvider({ children }: { children: ReactNode }) { setVisible(true) return } - if (!signMessage) { - const message = "This wallet doesn't support sign-in. Try Phantom, Solflare, or another wallet-standard wallet." + if (!signMessage && !walletSupportsSignIn(wallet)) { + const message = + "This wallet doesn't support sign-in. Try Phantom, Solflare, or another wallet-standard wallet." setError(message) throw new Error(message) } @@ -88,9 +89,30 @@ export function AuthSyncProvider({ children }: { children: ReactNode }) { try { const wallet58 = publicKey.toBase58() const { nonce, message } = await requestNonce(wallet58) - const sig = await signMessage(new TextEncoder().encode(message)) - const sigHex = bytesToHex(sig) - const verifyResult = await verifySignature(wallet58, nonce, sigHex) + + const siwsResult = await trySiws(wallet, wallet58, nonce) + let verifyResult: VerifyResult + if (siwsResult) { + try { + verifyResult = await verifySignature(wallet58, nonce, siwsResult.signatureHex, { + signedMessage: siwsResult.signedMessageBase64, + }) + } catch (err) { + // Server may not yet support the SIWS verify path; gracefully fall + // back to signMessage. Re-throws below if signMessage isn't + // available. + if (!signMessage) throw err + verifyResult = await runSignMessageFlow(signMessage, wallet58, nonce, message) + } + } else { + if (!signMessage) { + throw new Error( + "This wallet doesn't support sign-in. Try Phantom, Solflare, or another wallet-standard wallet.", + ) + } + verifyResult = await runSignMessageFlow(signMessage, wallet58, nonce, message) + } + const expiresAtSec = parseExpiryToEpoch(verifyResult.expiresIn) setAuth(verifyResult.token, verifyResult.isAdmin, expiresAtSec) lastWalletRef.current = wallet58 @@ -128,6 +150,12 @@ function bytesToHex(bytes: Uint8Array): string { .join('') } +function bytesToBase64(bytes: Uint8Array): string { + let binary = '' + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]) + return btoa(binary) +} + // Convert "24h" / "1h" / "300s" / "7d" relative TTL to absolute epoch seconds. // Falls back to now+1h for unparseable input rather than throwing — verify // already succeeded so we shouldn't drop the token over a parsing edge case. @@ -140,3 +168,88 @@ function parseExpiryToEpoch(expiresIn: string): number { const mul = { s: 1, m: 60, h: 3600, d: 86400 }[unit] return now + n * mul } + +interface VerifyResult { + token: string + isAdmin: boolean + expiresIn: string +} + +interface SiwsSignResult { + signatureHex: string + signedMessageBase64: string +} + +interface WalletAdapterWithSignIn { + signIn?: (input: { + domain: string + address: string + statement?: string + nonce: string + }) => Promise<{ + signature?: Uint8Array + signedMessage?: Uint8Array + }> +} + +interface WalletWithAdapter { + adapter?: unknown +} + +function getSignInAdapter( + wallet: unknown, +): WalletAdapterWithSignIn['signIn'] | null { + const adapter = (wallet as WalletWithAdapter | null)?.adapter as + | WalletAdapterWithSignIn + | undefined + if (!adapter || typeof adapter.signIn !== 'function') return null + return adapter.signIn.bind(adapter) +} + +function walletSupportsSignIn(wallet: unknown): boolean { + return getSignInAdapter(wallet) !== null +} + +function isUserRejection(err: unknown): boolean { + if (!(err instanceof Error)) return false + if (/reject|denied|user.*declin|cancel/i.test(err.message)) return true + const name = (err as { name?: string }).name + return Boolean(name && /reject|user/i.test(name)) +} + +async function trySiws( + wallet: unknown, + wallet58: string, + nonce: string, +): Promise { + const signIn = getSignInAdapter(wallet) + if (!signIn) return null + try { + const result = await signIn({ + domain: typeof window !== 'undefined' ? window.location.host : 'sipher.sip-protocol.org', + address: wallet58, + statement: 'Sign in to Sipher', + nonce, + }) + if (!result?.signature || !result.signedMessage) return null + if (result.signature.length === 0 || result.signedMessage.length === 0) return null + return { + signatureHex: bytesToHex(result.signature), + signedMessageBase64: bytesToBase64(result.signedMessage), + } + } catch (err) { + if (isUserRejection(err)) throw err + return null + } +} + +async function runSignMessageFlow( + signMessage: (message: Uint8Array) => Promise, + wallet58: string, + nonce: string, + message: string, +): Promise { + const sig = await signMessage(new TextEncoder().encode(message)) + const sigHex = bytesToHex(sig) + return verifySignature(wallet58, nonce, sigHex) +} diff --git a/app/src/providers/__tests__/AuthSyncProvider.test.tsx b/app/src/providers/__tests__/AuthSyncProvider.test.tsx index 5dcedf5..882dc00 100644 --- a/app/src/providers/__tests__/AuthSyncProvider.test.tsx +++ b/app/src/providers/__tests__/AuthSyncProvider.test.tsx @@ -318,6 +318,191 @@ describe('AuthSyncProvider — authenticate', () => { expect(useAppStore.getState().token).toBeNull() }) + it('uses SIWS when wallet exposes signIn and server accepts', async () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600 + const issuedToken = makeJwtForTest({ wallet: 'PhantomWallet', exp: futureExp }) + const mockSignIn = vi.fn().mockResolvedValue({ + signature: new Uint8Array([1, 2, 3, 4]), + signedMessage: new TextEncoder().encode('siwsmsg'), + }) + const mockSignMessage = vi.fn() + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'PhantomWallet' }, + wallet: { adapter: { signIn: mockSignIn } }, + signMessage: mockSignMessage, + disconnect: vi.fn(), + }) + ;(global.fetch as unknown as ReturnType) + .mockResolvedValueOnce({ ok: true, json: async () => ({ nonce: 'n', message: 'm' }) }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ token: issuedToken, isAdmin: false, expiresIn: '24h' }), + }) + + const { Capture, captured } = captureAuth() + render( + + + , + ) + await act(async () => { + await captured.current!.authenticate() + }) + + expect(mockSignIn).toHaveBeenCalledOnce() + expect(mockSignMessage).not.toHaveBeenCalled() + + const verifyCall = (global.fetch as unknown as ReturnType).mock.calls[1] + const verifyBody = JSON.parse(verifyCall[1].body as string) + expect(verifyBody.signedMessage).toBeTruthy() + expect(typeof verifyBody.signedMessage).toBe('string') + expect(useAppStore.getState().token).toBe(issuedToken) + }) + + it('falls back to signMessage when SIWS server returns 4xx (legacy server)', async () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600 + const issuedToken = makeJwtForTest({ wallet: 'PhantomWallet', exp: futureExp }) + const mockSignIn = vi.fn().mockResolvedValue({ + signature: new Uint8Array([1, 2, 3, 4]), + signedMessage: new TextEncoder().encode('siwsmsg'), + }) + const mockSignMessage = vi.fn().mockResolvedValue(new Uint8Array([9, 9, 9])) + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'PhantomWallet' }, + wallet: { adapter: { signIn: mockSignIn } }, + signMessage: mockSignMessage, + disconnect: vi.fn(), + }) + ;(global.fetch as unknown as ReturnType) + .mockResolvedValueOnce({ ok: true, json: async () => ({ nonce: 'n', message: 'm' }) }) + // First verify call (with signedMessage) returns 401 — legacy server + .mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: 'signature verification failed' }), + }) + // Second verify call (signMessage path) succeeds + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ token: issuedToken, isAdmin: false, expiresIn: '24h' }), + }) + + const { Capture, captured } = captureAuth() + render( + + + , + ) + await act(async () => { + await captured.current!.authenticate() + }) + + expect(mockSignIn).toHaveBeenCalledOnce() + expect(mockSignMessage).toHaveBeenCalledOnce() + expect(useAppStore.getState().token).toBe(issuedToken) + }) + + it('falls back to signMessage when SIWS returns no signature', async () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600 + const issuedToken = makeJwtForTest({ wallet: 'JupiterWallet', exp: futureExp }) + const mockSignIn = vi.fn().mockResolvedValue({}) + const mockSignMessage = vi.fn().mockResolvedValue(new Uint8Array([7, 8, 9])) + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'JupiterWallet' }, + wallet: { adapter: { signIn: mockSignIn } }, + signMessage: mockSignMessage, + disconnect: vi.fn(), + }) + ;(global.fetch as unknown as ReturnType) + .mockResolvedValueOnce({ ok: true, json: async () => ({ nonce: 'n', message: 'm' }) }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ token: issuedToken, isAdmin: false, expiresIn: '24h' }), + }) + + const { Capture, captured } = captureAuth() + render( + + + , + ) + await act(async () => { + await captured.current!.authenticate() + }) + + expect(mockSignIn).toHaveBeenCalledOnce() + expect(mockSignMessage).toHaveBeenCalledOnce() + expect(useAppStore.getState().token).toBe(issuedToken) + }) + + it('falls back to signMessage when SIWS throws non-rejection error', async () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600 + const issuedToken = makeJwtForTest({ wallet: 'W', exp: futureExp }) + const mockSignIn = vi.fn().mockRejectedValue(new Error('signIn not implemented')) + const mockSignMessage = vi.fn().mockResolvedValue(new Uint8Array([5, 5, 5])) + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'W' }, + wallet: { adapter: { signIn: mockSignIn } }, + signMessage: mockSignMessage, + disconnect: vi.fn(), + }) + ;(global.fetch as unknown as ReturnType) + .mockResolvedValueOnce({ ok: true, json: async () => ({ nonce: 'n', message: 'm' }) }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ token: issuedToken, isAdmin: false, expiresIn: '1h' }), + }) + + const { Capture, captured } = captureAuth() + render( + + + , + ) + await act(async () => { + await captured.current!.authenticate() + }) + + expect(mockSignMessage).toHaveBeenCalledOnce() + expect(useAppStore.getState().token).toBe(issuedToken) + }) + + it('propagates user rejection from SIWS without falling through', async () => { + const rejectErr = new Error('User rejected the request') + const mockSignIn = vi.fn().mockRejectedValue(rejectErr) + const mockSignMessage = vi.fn() + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'W' }, + wallet: { adapter: { signIn: mockSignIn } }, + signMessage: mockSignMessage, + disconnect: vi.fn(), + }) + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => ({ nonce: 'n', message: 'm' }), + }) + + const { Capture, captured } = captureAuth() + render( + + + , + ) + + await expect( + act(async () => { + await captured.current!.authenticate() + }), + ).rejects.toThrow(/User rejected/i) + expect(mockSignMessage).not.toHaveBeenCalled() + expect(useAppStore.getState().token).toBeNull() + }) + it('reports status=connecting while authenticate runs', async () => { const futureExp = Math.floor(Date.now() / 1000) + 3600 const okToken = makeJwtForTest({ wallet: 'W', exp: futureExp }) From 2f2ea2ce1ad73326a8a09594bb419aed32dcce86 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 6 May 2026 20:16:48 +0700 Subject: [PATCH 09/20] feat(app): disconnect cleanup + auto-clear on external disconnect Two failure modes addressed: 1. disconnect() previously called walletDisconnect() then clearAuth(). If walletDisconnect threw (extension closed mid-flight, hardware wallet unplugged) the JWT stayed in Zustand. Wrapped in try/finally so the JWT clears even if the wallet adapter rejects. 2. External disconnects (user clicks Disconnect in Phantom extension, browser wipes wallet-adapter state, etc.) bypass our disconnect() entirely. Added a useEffect that clears auth when connected goes false while a token is still in the store. 3 new tests: explicit disconnect happy path, disconnect-when-walletDisconnect- throws, external-disconnect auto-clears. Resolves FE H-6 (auth state desync between wallet-adapter and Zustand). --- app/src/providers/AuthSyncProvider.tsx | 19 +++- .../__tests__/AuthSyncProvider.test.tsx | 101 ++++++++++++++++++ 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/app/src/providers/AuthSyncProvider.tsx b/app/src/providers/AuthSyncProvider.tsx index a9d910c..9e874bd 100644 --- a/app/src/providers/AuthSyncProvider.tsx +++ b/app/src/providers/AuthSyncProvider.tsx @@ -64,6 +64,17 @@ export function AuthSyncProvider({ children }: { children: ReactNode }) { } }, [connected, publicKey, token, clearAuth]) + // Auto-clear when the wallet disconnects externally (user clicks + // Disconnect in their Phantom extension, locks their hardware wallet, + // browser wipes wallet-adapter state, etc.). The other reconciliation + // effects only fire when `connected` is true, so they can't catch this. + useEffect(() => { + if (!connected && (token !== null || isAdmin)) { + clearAuth() + lastWalletRef.current = null + } + }, [connected, token, isAdmin, clearAuth]) + const status: AuthStatus = useMemo(() => { if (authenticating) return 'connecting' if (!connected || !publicKey) return 'unauthed' @@ -126,8 +137,12 @@ export function AuthSyncProvider({ children }: { children: ReactNode }) { } const disconnect = async () => { - await walletDisconnect() - clearAuth() + try { + await walletDisconnect() + } finally { + clearAuth() + lastWalletRef.current = null + } } const value: AuthState = { diff --git a/app/src/providers/__tests__/AuthSyncProvider.test.tsx b/app/src/providers/__tests__/AuthSyncProvider.test.tsx index 882dc00..d2b54d6 100644 --- a/app/src/providers/__tests__/AuthSyncProvider.test.tsx +++ b/app/src/providers/__tests__/AuthSyncProvider.test.tsx @@ -145,6 +145,46 @@ describe('AuthSyncProvider — status machine', () => { expect(screen.getByTestId('isAdmin').textContent).toBe('yes') }) + it('clears auth automatically when wallet disconnects externally', () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600 + const validToken = makeJwtForTest({ wallet: 'W', exp: futureExp }) + + // Start connected with a valid token + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'W' }, + wallet: { adapter: {} }, + signMessage: vi.fn(), + disconnect: vi.fn(), + }) + useAppStore.setState({ token: validToken, isAdmin: false, expiresAt: futureExp }, false) + + const { rerender } = render( + +
+ , + ) + expect(useAppStore.getState().token).toBe(validToken) + + // Simulate external disconnect + mockedUseWallet.mockReturnValue({ + connected: false, + publicKey: null, + wallet: null, + signMessage: undefined, + disconnect: vi.fn(), + }) + rerender( + +
+ , + ) + + expect(useAppStore.getState().token).toBeNull() + expect(useAppStore.getState().isAdmin).toBe(false) + expect(useAppStore.getState().expiresAt).toBeNull() + }) + it('throws when useAuthState used outside provider', () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) function Bad() { @@ -503,6 +543,67 @@ describe('AuthSyncProvider — authenticate', () => { expect(useAppStore.getState().token).toBeNull() }) + it('disconnect() calls wallet.disconnect AND clears auth', async () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600 + const validToken = makeJwtForTest({ wallet: 'W', exp: futureExp }) + useAppStore.setState({ token: validToken, isAdmin: true, expiresAt: futureExp }, false) + + const mockDisconnect = vi.fn().mockResolvedValue(undefined) + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'W' }, + wallet: { adapter: {} }, + signMessage: vi.fn(), + disconnect: mockDisconnect, + }) + + const { Capture, captured } = captureAuth() + render( + + + , + ) + + await act(async () => { + await captured.current!.disconnect() + }) + + expect(mockDisconnect).toHaveBeenCalled() + const state = useAppStore.getState() + expect(state.token).toBeNull() + expect(state.isAdmin).toBe(false) + expect(state.expiresAt).toBeNull() + }) + + it('disconnect() still clears auth when walletDisconnect throws', async () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600 + const validToken = makeJwtForTest({ wallet: 'W', exp: futureExp }) + useAppStore.setState({ token: validToken, isAdmin: false, expiresAt: futureExp }, false) + + const mockDisconnect = vi.fn().mockRejectedValue(new Error('extension closed')) + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'W' }, + wallet: { adapter: {} }, + signMessage: vi.fn(), + disconnect: mockDisconnect, + }) + + const { Capture, captured } = captureAuth() + render( + + + , + ) + + await expect( + act(async () => { + await captured.current!.disconnect() + }), + ).rejects.toThrow(/extension closed/) + expect(useAppStore.getState().token).toBeNull() + }) + it('reports status=connecting while authenticate runs', async () => { const futureExp = Math.floor(Date.now() / 1000) + 3600 const okToken = makeJwtForTest({ wallet: 'W', exp: futureExp }) From 7ba2481b38bee770926f7eb22c6870e8afb30dd9 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 6 May 2026 20:20:14 +0700 Subject: [PATCH 10/20] feat(app): preemptive JWT refresh + expiry-driven cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schedules two timers per JWT lifetime: - Cleanup timer: fires at exact expiry (or immediately if already expired on hydration), clearing the auth fields if refresh did not succeed. - Refresh timer: fires (remainingSec - 5min) before expiry, calls /api/auth/refresh, swaps in the new token + expiresAt on success. If the JWT is already inside the 5min window when the effect mounts (cold reload near expiry, long-running tab), refresh fires immediately without waiting for a timer. Refresh failures are intentionally swallowed — the cleanup timer + the upcoming 401 interceptor (Task A9) handle the reauth surface. Effect cleanup function clears both timers when token/expiresAt change (prevents stale timers leaking after refresh succeeds and re-runs the effect with new values). 4 new tests: clears at expiry, immediate refresh in window, refresh scheduled near expiry far from now, refresh-failure-then-clear path. Resolves FE H-2 (no expiry watch / refresh — silent 401s after 1h). --- app/src/providers/AuthSyncProvider.tsx | 51 ++++++ .../__tests__/AuthSyncProvider.test.tsx | 157 ++++++++++++++++++ 2 files changed, 208 insertions(+) diff --git a/app/src/providers/AuthSyncProvider.tsx b/app/src/providers/AuthSyncProvider.tsx index 9e874bd..91d20ba 100644 --- a/app/src/providers/AuthSyncProvider.tsx +++ b/app/src/providers/AuthSyncProvider.tsx @@ -4,6 +4,7 @@ import { useWalletModal } from '@solana/wallet-adapter-react-ui' import { useAppStore } from '../stores/app' import { decodeJwtPayload, isJwtExpired } from '../lib/jwt' import { requestNonce, verifySignature } from '../api/auth' +import { refreshToken } from '../api/refresh' export type AuthStatus = 'connecting' | 'unauthed' | 'authed' | 'expired' | 'error' @@ -75,6 +76,56 @@ export function AuthSyncProvider({ children }: { children: ReactNode }) { } }, [connected, token, isAdmin, clearAuth]) + // Expiry watcher: schedule a preemptive refresh inside the last 5min of + // the JWT lifetime, plus a cleanup timer that fires at exact expiry to + // ensure the store doesn't keep a stale token if refresh failed. + useEffect(() => { + if (!token || !expiresAt) return + + const nowSec = Math.floor(Date.now() / 1000) + const remainingSec = expiresAt - nowSec + const fiveMinSec = 5 * 60 + + const clearTimer = setTimeout( + () => { + clearAuth() + }, + Math.max(0, remainingSec) * 1000, + ) + + let refreshTimer: ReturnType | null = null + + const attemptRefresh = async (currentToken: string) => { + try { + const result = await refreshToken(currentToken) + if (result) { + const newExp = parseExpiryToEpoch(result.expiresIn) + setAuth(result.token, isAdmin, newExp) + } + } catch { + // Refresh failed — let the clearTimer handle expiry. We don't + // surface this error: the 401 interceptor will catch the next + // outgoing request and trigger UI re-auth. + } + } + + if (remainingSec > fiveMinSec) { + refreshTimer = setTimeout( + () => { + void attemptRefresh(token) + }, + (remainingSec - fiveMinSec) * 1000, + ) + } else if (remainingSec > 0) { + void attemptRefresh(token) + } + + return () => { + clearTimeout(clearTimer) + if (refreshTimer) clearTimeout(refreshTimer) + } + }, [token, expiresAt, isAdmin, clearAuth, setAuth]) + const status: AuthStatus = useMemo(() => { if (authenticating) return 'connecting' if (!connected || !publicKey) return 'unauthed' diff --git a/app/src/providers/__tests__/AuthSyncProvider.test.tsx b/app/src/providers/__tests__/AuthSyncProvider.test.tsx index d2b54d6..ff1a5b7 100644 --- a/app/src/providers/__tests__/AuthSyncProvider.test.tsx +++ b/app/src/providers/__tests__/AuthSyncProvider.test.tsx @@ -196,6 +196,163 @@ describe('AuthSyncProvider — status machine', () => { }) }) +describe('AuthSyncProvider — expiry watcher', () => { + const originalFetch = global.fetch + + beforeEach(() => { + vi.useFakeTimers() + useAppStore.setState({ token: null, isAdmin: false, expiresAt: null }, false) + mockedUseWallet.mockReset() + global.fetch = vi.fn() as unknown as typeof fetch + }) + + afterEach(() => { + vi.useRealTimers() + global.fetch = originalFetch + }) + + it('clears token when expiry timer fires', () => { + const expiresInSec = 60 + const exp = Math.floor(Date.now() / 1000) + expiresInSec + const tok = makeJwtForTest({ wallet: 'W', exp }) + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'W' }, + wallet: { adapter: {} }, + signMessage: vi.fn(), + disconnect: vi.fn(), + }) + useAppStore.setState({ token: tok, isAdmin: false, expiresAt: exp }, false) + + render( + +
+ , + ) + expect(useAppStore.getState().token).toBe(tok) + + act(() => { + vi.advanceTimersByTime(expiresInSec * 1000 + 1000) + }) + + expect(useAppStore.getState().token).toBeNull() + }) + + it('attempts immediate refresh when already within 5min window', async () => { + const expiresInSec = 60 + const exp = Math.floor(Date.now() / 1000) + expiresInSec + const oldTok = makeJwtForTest({ wallet: 'W', exp }) + const newExp = Math.floor(Date.now() / 1000) + 86400 + const newTok = makeJwtForTest({ wallet: 'W', exp: newExp }) + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'W' }, + wallet: { adapter: {} }, + signMessage: vi.fn(), + disconnect: vi.fn(), + }) + // Only the FIRST refresh succeeds; subsequent calls (triggered by the + // re-effect after token swap schedules another refresh in the future) + // would only fire if we advanced the clock by 23h55m, which we don't. + ;(global.fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => ({ token: newTok, expiresIn: '24h' }), + }) + useAppStore.setState({ token: oldTok, isAdmin: false, expiresAt: exp }, false) + + render( + +
+ , + ) + + // Drain microtasks for the IIFE refresh promise chain (no time advance — + // the new long-window timers from the post-refresh re-effect must not + // fire in this test). + await act(async () => { + for (let i = 0; i < 10; i++) await Promise.resolve() + }) + + expect(global.fetch).toHaveBeenCalledWith( + '/api/auth/refresh', + expect.objectContaining({ method: 'POST' }), + ) + expect(useAppStore.getState().token).toBe(newTok) + }) + + it('schedules refresh near expiry when JWT has more than 5min remaining', async () => { + const expiresInSec = 24 * 3600 + const exp = Math.floor(Date.now() / 1000) + expiresInSec + const oldTok = makeJwtForTest({ wallet: 'W', exp }) + const newExp = Math.floor(Date.now() / 1000) + expiresInSec + 24 * 3600 + const newTok = makeJwtForTest({ wallet: 'W', exp: newExp }) + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'W' }, + wallet: { adapter: {} }, + signMessage: vi.fn(), + disconnect: vi.fn(), + }) + ;(global.fetch as unknown as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ token: newTok, expiresIn: '24h' }), + }) + useAppStore.setState({ token: oldTok, isAdmin: false, expiresAt: exp }, false) + + render( + +
+ , + ) + + // Should NOT refresh yet — we're far from expiry + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(global.fetch).not.toHaveBeenCalled() + + // Advance to within the 5-minute window + await act(async () => { + await vi.advanceTimersByTimeAsync((expiresInSec - 5 * 60 - 60) * 1000 + 1000) + }) + expect(global.fetch).toHaveBeenCalledWith( + '/api/auth/refresh', + expect.objectContaining({ method: 'POST' }), + ) + }) + + it('clears token if refresh fails and expiry passes', async () => { + const expiresInSec = 30 + const exp = Math.floor(Date.now() / 1000) + expiresInSec + const tok = makeJwtForTest({ wallet: 'W', exp }) + mockedUseWallet.mockReturnValue({ + connected: true, + publicKey: { toBase58: () => 'W' }, + wallet: { adapter: {} }, + signMessage: vi.fn(), + disconnect: vi.fn(), + }) + ;(global.fetch as unknown as ReturnType).mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ error: 'invalid' }), + }) + useAppStore.setState({ token: tok, isAdmin: false, expiresAt: exp }, false) + + render( + +
+ , + ) + + await act(async () => { + await vi.advanceTimersByTimeAsync(expiresInSec * 1000 + 1000) + }) + + expect(useAppStore.getState().token).toBeNull() + }) +}) + describe('AuthSyncProvider — authenticate', () => { const originalFetch = global.fetch From b2bfbca9a0fb46bf70e4cd7ea00f077d7046969f Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 6 May 2026 20:23:56 +0700 Subject: [PATCH 11/20] feat(app): global 401 interceptor in apiFetch + toast on session expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apiFetch gains a module-scope interceptor registry (registerAuthInterceptor). AuthSyncProvider registers a single handler on mount, unregisters on unmount. Any 401 from any backend endpoint now triggers: - clearAuth() — wipes token/isAdmin/expiresAt from Zustand - warn toast \"Session expired — please sign in again.\" with 12s persistence - 'Sign in' action button that re-runs authenticate() (uses authenticateRef to keep the latest closure without invalidating the interceptor effect) The interceptor handler is wrapped in try/catch so a buggy handler can never block the underlying request from throwing — auth-loss UX is best effort. App.tsx provider tree updated: ConnectionProvider > WalletProvider > WalletModalProvider > ToastProvider > AuthSyncProvider > AppShell. ToastProvider goes outside AuthSyncProvider because AuthSync calls useToast(); AppShell goes inside both so any component can read auth state via useAuthState(). Resolves FE H-3 (no global 401 interceptor — every fetch caller reinvented it, leading to inconsistent error UX) and FE H-4 (raw \"invalid or expired token\" string leaked into chat). 9 new client.ts tests cover happy path, structured error envelope, legacy string error, 401 calls interceptor, non-401 doesn't, interceptor crash doesn't propagate. --- app/src/App.tsx | 8 +- app/src/api/__tests__/client.test.ts | 117 ++++++++++++++++++ app/src/api/client.ts | 38 +++++- app/src/providers/AuthSyncProvider.tsx | 30 +++++ .../__tests__/AuthSyncProvider.test.tsx | 7 ++ 5 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 app/src/api/__tests__/client.test.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index 146a829..1bba348 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -20,6 +20,8 @@ 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) @@ -105,7 +107,11 @@ export default function App() { - + + + + + diff --git a/app/src/api/__tests__/client.test.ts b/app/src/api/__tests__/client.test.ts new file mode 100644 index 0000000..842a13d --- /dev/null +++ b/app/src/api/__tests__/client.test.ts @@ -0,0 +1,117 @@ +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/) + }) +}) diff --git a/app/src/api/client.ts b/app/src/api/client.ts index ffb619b..ff21700 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,31 @@ 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}`) } return res.json() as Promise } diff --git a/app/src/providers/AuthSyncProvider.tsx b/app/src/providers/AuthSyncProvider.tsx index 91d20ba..3a5d7cb 100644 --- a/app/src/providers/AuthSyncProvider.tsx +++ b/app/src/providers/AuthSyncProvider.tsx @@ -5,6 +5,8 @@ import { useAppStore } from '../stores/app' import { decodeJwtPayload, isJwtExpired } from '../lib/jwt' import { requestNonce, verifySignature } from '../api/auth' import { refreshToken } from '../api/refresh' +import { registerAuthInterceptor } from '../api/client' +import { useToast } from './ToastProvider' export type AuthStatus = 'connecting' | 'unauthed' | 'authed' | 'expired' | 'error' @@ -30,6 +32,7 @@ export function useAuthSyncContext(): AuthState { export function AuthSyncProvider({ children }: { children: ReactNode }) { const { connected, publicKey, wallet, signMessage, disconnect: walletDisconnect } = useWallet() const { setVisible } = useWalletModal() + const { show: showToast } = useToast() const token = useAppStore((s) => s.token) const isAdmin = useAppStore((s) => s.isAdmin) const expiresAt = useAppStore((s) => s.expiresAt) @@ -39,6 +42,7 @@ export function AuthSyncProvider({ children }: { children: ReactNode }) { const [error, setError] = useState(null) const [authenticating, setAuthenticating] = useState(false) const lastWalletRef = useRef(null) + const authenticateRef = useRef<() => Promise>(() => Promise.resolve()) // Track wallet identity across renders; clear auth when the wallet changes. useEffect(() => { @@ -196,6 +200,32 @@ export function AuthSyncProvider({ children }: { children: ReactNode }) { } } + authenticateRef.current = authenticate + + // Wire the global 401 interceptor: any apiFetch that returns 401 clears + // the token and surfaces a "Session expired — Sign in" toast with a CTA + // that re-runs authenticate(). + useEffect(() => { + registerAuthInterceptor(() => { + clearAuth() + showToast({ + message: 'Session expired — please sign in again.', + kind: 'warn', + durationMs: 12_000, + action: { + label: 'Sign in', + onClick: () => { + authenticateRef.current().catch(() => { + // Errors already surfaced via setError + toast on failure; + // swallow here so the toast click doesn't bubble. + }) + }, + }, + }) + }) + return () => registerAuthInterceptor(null) + }, [clearAuth, showToast]) + const value: AuthState = { status, token, diff --git a/app/src/providers/__tests__/AuthSyncProvider.test.tsx b/app/src/providers/__tests__/AuthSyncProvider.test.tsx index ff1a5b7..fedb305 100644 --- a/app/src/providers/__tests__/AuthSyncProvider.test.tsx +++ b/app/src/providers/__tests__/AuthSyncProvider.test.tsx @@ -4,6 +4,13 @@ import { AuthSyncProvider } from '../AuthSyncProvider' import { useAuthState, type AuthState } from '../../hooks/useAuthState' import { useAppStore } from '../../stores/app' +const mockToastShow = vi.fn(() => 'toast-id') +const mockToastDismiss = vi.fn() +vi.mock('../ToastProvider', () => ({ + useToast: () => ({ show: mockToastShow, dismiss: mockToastDismiss }), + ToastProvider: ({ children }: { children: React.ReactNode }) => children, +})) + const mockSetVisible = vi.fn() vi.mock('@solana/wallet-adapter-react', () => ({ From d978c47eb2aa47ce4ee5e52e6bbfb5978f14181d Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 6 May 2026 20:24:56 +0700 Subject: [PATCH 12/20] feat(app): WalletDropdown component (Copy / Re-sign / Disconnect) Plain Tailwind + Phosphor implementation; no Radix dep added per spec D6 (lean deps). Closes on outside mousedown, Escape key, or action click. role=menu/menuitem + aria-haspopup/aria-expanded for screen readers. Used by Header.tsx in Task A12 to replace the unclickable wallet pill. Resolves FE H-5 (no desktop disconnect path) once wired in. 9 tests cover render, open/close toggle, three actions (each invokes its callback and closes), outside click, Escape, aria-expanded reflection. --- app/src/components/WalletDropdown.tsx | 82 ++++++++++ .../__tests__/WalletDropdown.test.tsx | 143 ++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 app/src/components/WalletDropdown.tsx create mode 100644 app/src/components/__tests__/WalletDropdown.test.tsx diff --git a/app/src/components/WalletDropdown.tsx b/app/src/components/WalletDropdown.tsx new file mode 100644 index 0000000..73e496b --- /dev/null +++ b/app/src/components/WalletDropdown.tsx @@ -0,0 +1,82 @@ +import { useEffect, useRef, useState } from 'react' +import { Copy, ArrowsClockwise, Plug, CaretDown } from '@phosphor-icons/react' + +interface Props { + address: string + onCopy: () => void + onReSignIn: () => void + onDisconnect: () => void +} + +export function WalletDropdown({ address, onCopy, onReSignIn, onDisconnect }: Props) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + const short = `${address.slice(0, 4)}...${address.slice(-4)}` + + useEffect(() => { + if (!open) return + const onMouseDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('mousedown', onMouseDown) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onMouseDown) + document.removeEventListener('keydown', onKey) + } + }, [open]) + + const handleAction = (cb: () => void) => () => { + cb() + setOpen(false) + } + + return ( +
+ + {open && ( +
+ + + +
+ )} +
+ ) +} diff --git a/app/src/components/__tests__/WalletDropdown.test.tsx b/app/src/components/__tests__/WalletDropdown.test.tsx new file mode 100644 index 0000000..56b44f3 --- /dev/null +++ b/app/src/components/__tests__/WalletDropdown.test.tsx @@ -0,0 +1,143 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { WalletDropdown } from '../WalletDropdown' + +const FULL = 'HciZTd6rR7YsaS5ZNThx9KdgqSimxwMzJgs2j98U25En' + +describe('WalletDropdown', () => { + it('renders pill with shortened address', () => { + render( + , + ) + expect(screen.getByText('HciZ...25En')).toBeInTheDocument() + }) + + it('opens dropdown on click and shows three actions', () => { + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: /HciZ\.\.\.25En/ })) + expect(screen.getByText('Copy address')).toBeInTheDocument() + expect(screen.getByText(/Re-sign in/i)).toBeInTheDocument() + expect(screen.getByText('Disconnect')).toBeInTheDocument() + }) + + it('toggles closed when pill clicked again', () => { + render( + , + ) + const pill = screen.getByRole('button', { name: /HciZ\.\.\.25En/ }) + fireEvent.click(pill) + expect(screen.getByText('Copy address')).toBeInTheDocument() + fireEvent.click(pill) + expect(screen.queryByText('Copy address')).not.toBeInTheDocument() + }) + + it('closes on action click and invokes callback', () => { + const onDisconnect = vi.fn() + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: /HciZ\.\.\.25En/ })) + fireEvent.click(screen.getByText('Disconnect')) + expect(onDisconnect).toHaveBeenCalledOnce() + expect(screen.queryByText('Copy address')).not.toBeInTheDocument() + }) + + it('Copy action invokes onCopy', () => { + const onCopy = vi.fn() + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: /HciZ\.\.\.25En/ })) + fireEvent.click(screen.getByText('Copy address')) + expect(onCopy).toHaveBeenCalledOnce() + }) + + it('Re-sign action invokes onReSignIn', () => { + const onReSignIn = vi.fn() + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: /HciZ\.\.\.25En/ })) + fireEvent.click(screen.getByText(/Re-sign in/i)) + expect(onReSignIn).toHaveBeenCalledOnce() + }) + + it('closes on outside mousedown', () => { + render( +
+ + +
, + ) + fireEvent.click(screen.getByRole('button', { name: /HciZ\.\.\.25En/ })) + expect(screen.getByText('Copy address')).toBeInTheDocument() + fireEvent.mouseDown(screen.getByText('outside')) + expect(screen.queryByText('Copy address')).not.toBeInTheDocument() + }) + + it('closes on Escape key', () => { + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: /HciZ\.\.\.25En/ })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByText('Copy address')).not.toBeInTheDocument() + }) + + it('aria-expanded reflects open state', () => { + render( + , + ) + const pill = screen.getByRole('button', { name: /HciZ\.\.\.25En/ }) + expect(pill).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(pill) + expect(pill).toHaveAttribute('aria-expanded', 'true') + }) +}) From 0c8e452b3b3533a3a91c43d3403e40109edc5e73 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 7 May 2026 06:53:04 +0700 Subject: [PATCH 13/20] style(app): strip semicolons from jwt.ts (catch-up to 80fa90c) Commit 80fa90c claimed jwt.ts was already compliant with the project no-semicolons rule, but the file still carried 9 trailing semicolons from its initial introduction in 840c409. This catches it up. Also drops a what-comment ("Use base64url decoding; atob handles padded base64") that just narrated the next line. The why-comment on the malformed-token branch is preserved because the fail-secure intent (null exp -> treat as expired) is non-obvious from the code. No behavior change. 124/124 app tests still pass. --- app/src/lib/jwt.ts | 41 ++++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/app/src/lib/jwt.ts b/app/src/lib/jwt.ts index 1dedd13..2910dde 100644 --- a/app/src/lib/jwt.ts +++ b/app/src/lib/jwt.ts @@ -1,37 +1,36 @@ // app/src/lib/jwt.ts export interface JwtPayload { - wallet: string; - iat: number; - exp: number; - isAdmin?: boolean; + wallet: string + iat: number + exp: number + isAdmin?: boolean } export function decodeJwtPayload(token: string): JwtPayload | null { - const parts = token.split('.'); - if (parts.length !== 3) return null; + const parts = token.split('.') + if (parts.length !== 3) return null try { - // Use base64url decoding; atob handles padded base64 - const padded = parts[1] + '='.repeat((4 - (parts[1].length % 4)) % 4); - const json = atob(padded.replace(/-/g, '+').replace(/_/g, '/')); - const obj = JSON.parse(json); - if (typeof obj !== 'object' || obj === null) return null; - if (typeof obj.wallet !== 'string') return null; - if (typeof obj.iat !== 'number') return null; - if (typeof obj.exp !== 'number') return null; - return obj as JwtPayload; + const padded = parts[1] + '='.repeat((4 - (parts[1].length % 4)) % 4) + const json = atob(padded.replace(/-/g, '+').replace(/_/g, '/')) + const obj = JSON.parse(json) + if (typeof obj !== 'object' || obj === null) return null + if (typeof obj.wallet !== 'string') return null + if (typeof obj.iat !== 'number') return null + if (typeof obj.exp !== 'number') return null + return obj as JwtPayload } catch { - return null; + return null } } export function getJwtExpiresAt(token: string): number | null { - const payload = decodeJwtPayload(token); - return payload?.exp ?? null; + const payload = decodeJwtPayload(token) + return payload?.exp ?? null } export function isJwtExpired(token: string, nowSeconds = Math.floor(Date.now() / 1000)): boolean { - const exp = getJwtExpiresAt(token); - if (exp === null) return true; // defensive: treat malformed as expired - return nowSeconds >= exp; + const exp = getJwtExpiresAt(token) + if (exp === null) return true // defensive: treat malformed as expired + return nowSeconds >= exp } From b5f9ed4c44606f8c2d7d5adb6b951718bbf14760 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 7 May 2026 06:56:36 +0700 Subject: [PATCH 14/20] feat(app): Header consumes useAuthState + WalletDropdown Replaces the unclickable wallet pill with a status-aware switch: - unauthed | connecting -> Connect button -> authenticate(); on rejection, surface error via toast. - expired -> amber "Re-sign in" button -> authenticate(); preserves the pre-auth UI hint that the session lapsed instead of silently sending the user through a fresh Connect. - authed + publicKey -> WalletDropdown with Copy / Re-sign / Disconnect actions. Also drops the local useWallet + useWalletModal reads. authenticate() in AuthSyncProvider already routes to setVisible(true) when there is no connected wallet, so the modal still opens on first Connect click. Resolves FE H-3 (autoConnect produces fake-authed UI with no recovery) and FE H-5 (no Disconnect path on desktop). 10 new tests in Header.test.tsx; 134/134 app tests green. --- app/src/components/Header.tsx | 55 +++++--- app/src/components/__tests__/Header.test.tsx | 138 +++++++++++++++++++ 2 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 app/src/components/__tests__/Header.test.tsx diff --git a/app/src/components/Header.tsx b/app/src/components/Header.tsx index 8ef4a5f..d5e3d53 100644 --- a/app/src/components/Header.tsx +++ b/app/src/components/Header.tsx @@ -1,5 +1,3 @@ -import { useWallet } from '@solana/wallet-adapter-react' -import { useWalletModal } from '@solana/wallet-adapter-react-ui' import { ChartBar, Vault, @@ -8,9 +6,10 @@ import { ChatCircle, } from '@phosphor-icons/react' import { useAppStore, type View } from '../stores/app' -import { useAuth } from '../hooks/useAuth' +import { useAuthState } from '../hooks/useAuthState' +import { useToast } from '../providers/ToastProvider' import AgentDot from './AgentDot' -import { truncateAddress } from '../lib/format' +import { WalletDropdown } from './WalletDropdown' import { useNetworkConfigStore } from '../lib/networkConfig' interface Tab { @@ -30,9 +29,8 @@ const TABS: Tab[] = [ ] export default function Header() { - const { publicKey, connected } = useWallet() - const { setVisible } = useWalletModal() - const { isAuthenticated, authenticate, isAdmin } = useAuth() + const { status, publicKey, authenticate, disconnect, isAdmin } = useAuthState() + const { show: showToast } = useToast() const activeView = useAppStore((s) => s.activeView) const setActiveView = useAppStore((s) => s.setActiveView) const network = useNetworkConfigStore((s) => s.config?.network ?? 'mainnet') @@ -42,6 +40,24 @@ export default function Header() { return true }) + const handleConnectOrSignIn = () => { + authenticate().catch((err: unknown) => { + const message = err instanceof Error ? err.message : 'Sign-in failed' + showToast({ message, kind: 'error' }) + }) + } + + const handleCopy = async () => { + if (!publicKey) return + await navigator.clipboard.writeText(publicKey) + showToast({ message: 'Address copied', kind: 'success', durationMs: 3000 }) + } + + const handleDisconnect = async () => { + await disconnect() + showToast({ message: 'Disconnected', kind: 'info', durationMs: 3000 }) + } + return (
@@ -81,23 +97,24 @@ export default function Header() { {network} - {connected && publicKey ? ( + {status === 'authed' && publicKey ? ( + + ) : status === 'expired' ? ( ) : (