# Direct API Architecture Plan v2 ## Executive Summary This document outlines a comprehensive plan to migrate from proxy-endpoint-based API calls to direct API calls in the Hensei SvelteKit application, with proper token lifecycle management, SSR support, and security hardening. ### Key Improvements in v2 - **Token Bootstrap**: Proper SSR token initialization via hooks and layout - **Refresh Logic**: Single in-flight refresh with proper gating - **Security**: CSP, short TTL tokens, proper CORS configuration - **DX**: TanStack Query integration, proper error handling ## Architecture Overview ### Token Flow ``` 1. Initial Load (SSR): hooks.server.ts → Read refresh cookie → Exchange for access token → Pass to client 2. Client Hydration: +layout.svelte → Receive token from SSR → Initialize auth store 3. API Calls: Adapter → Check token expiry → Use token or refresh → Retry on 401 4. Token Refresh: Single in-flight promise → Exchange refresh token → Update store ``` ## Implementation Plan ### Phase 1: Core Authentication Infrastructure #### 1.1 Auth Store with Refresh Management `/src/lib/stores/auth.store.ts`: ```typescript import { writable, get } from 'svelte/store' import { goto } from '$app/navigation' import { PUBLIC_SIERO_API_URL } from '$env/static/public' interface AuthState { accessToken: string | null user: UserInfo | null expiresAt: Date | null refreshPromise: Promise | null } function createAuthStore() { const { subscribe, set, update } = writable({ accessToken: null, user: null, expiresAt: null, refreshPromise: null }) const API = `${PUBLIC_SIERO_API_URL ?? 'http://localhost:3000'}/api/v1` return { subscribe, setAuth: (token: string, user: UserInfo, expiresAt: Date) => { set({ accessToken: token, user, expiresAt, refreshPromise: null }) }, clearAuth: () => { set({ accessToken: null, user: null, expiresAt: null, refreshPromise: null }) goto('/login') }, getToken: () => { const state = get(authStore) // Check if token needs refresh (60s buffer) if (state.expiresAt && state.accessToken) { const now = new Date() const buffer = new Date(state.expiresAt.getTime() - 60000) if (now >= buffer) { // Token expired or about to expire, trigger refresh return null } } return state.accessToken }, async refreshToken(fetcher: typeof fetch = fetch): Promise { return update(state => { // If refresh already in progress, return existing promise if (state.refreshPromise) { return state } // Create new refresh promise const promise = (async () => { try { const response = await fetcher(`${API}/auth/refresh`, { method: 'POST', credentials: 'include' }) if (!response.ok) { this.clearAuth() return false } const { access_token, user, expires_in } = await response.json() const expiresAt = new Date(Date.now() + expires_in * 1000) this.setAuth(access_token, user, expiresAt) return true } catch { this.clearAuth() return false } finally { update(s => ({ ...s, refreshPromise: null })) } })() return { ...state, refreshPromise: promise } }).refreshPromise } } } export const authStore = createAuthStore() ``` #### 1.2 Server Hooks for SSR `/src/hooks.server.ts`: ```typescript import type { Handle, HandleFetch } from '@sveltejs/kit' import { PRIVATE_SIERO_API_URL } from '$env/static/private' import { REFRESH_COOKIE } from '$lib/auth/cookies' const API_BASE = PRIVATE_SIERO_API_URL || 'http://localhost:3000' export const handle: Handle = async ({ event, resolve }) => { // Initialize locals event.locals.user = null event.locals.accessToken = null event.locals.expiresAt = null // Check for refresh token const refreshToken = event.cookies.get(REFRESH_COOKIE) if (refreshToken) { try { // Bootstrap session - exchange refresh for access token const response = await fetch(`${API_BASE}/api/v1/auth/bootstrap`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Cookie': event.request.headers.get('cookie') ?? '' }, credentials: 'include' }) if (response.ok) { const { access_token, user, expires_in } = await response.json() event.locals.user = user event.locals.accessToken = access_token event.locals.expiresAt = new Date(Date.now() + expires_in * 1000) } } catch (error) { console.error('Session bootstrap failed:', error) } } // Add CSP headers for security const response = await resolve(event) response.headers.set( 'Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' http://localhost:3000" ) return response } export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { // For SSR fetches to Rails API, attach access token const isApiCall = request.url.startsWith(API_BASE) if (isApiCall && event.locals?.accessToken) { const headers = new Headers(request.headers) headers.set('Authorization', `Bearer ${event.locals.accessToken}`) request = new Request(request, { headers, credentials: 'include' }) } return fetch(request) } ``` #### 1.3 Layout Server Load `/src/routes/+layout.server.ts`: ```typescript import type { LayoutServerLoad } from './$types' export const load: LayoutServerLoad = async ({ locals }) => { return { user: locals.user, accessToken: locals.accessToken, expiresAt: locals.expiresAt?.toISOString() ?? null } } ``` #### 1.4 Layout Client Hydration `/src/routes/+layout.svelte`: ```typescript ``` ### Phase 2: Update Base Adapter with Smart Refresh #### 2.1 Enhanced Base Adapter `/src/lib/api/adapters/base.adapter.ts`: ```typescript import { authStore } from '$lib/stores/auth.store' import { get } from 'svelte/store' import { PUBLIC_SIERO_API_URL } from '$env/static/public' import { normalizeError, AdapterError } from './errors' const API_BASE = `${PUBLIC_SIERO_API_URL ?? 'http://localhost:3000'}/api/v1` interface RequestOptions extends RequestInit { retry?: boolean timeout?: number params?: Record } export abstract class BaseAdapter { constructor(protected fetcher?: typeof fetch) {} protected async request( path: string, options: RequestOptions = {} ): Promise { const { retry = false, timeout = 30000, params, ...init } = options // Build URL with params const url = this.buildUrl(path, params) // Get current token (checks expiry) let token = authStore.getToken() // If token is null (expired), try refresh if (!token && !retry) { const refreshed = await authStore.refreshToken(this.fetcher ?? fetch) if (refreshed) { token = authStore.getToken() } } // Use provided fetcher or global fetch const fetcher = this.fetcher ?? fetch // Create abort controller for timeout const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), timeout) try { const response = await fetcher(url, { ...init, signal: controller.signal, credentials: 'include', headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}), ...(init.headers ?? {}) }, body: this.prepareBody(init.body) }) clearTimeout(timeoutId) // Handle 401 with single retry if (response.status === 401 && !retry) { const refreshed = await authStore.refreshToken(fetcher) if (refreshed) { return this.request(path, { ...options, retry: true }) } authStore.clearAuth() throw new AdapterError('Unauthorized', 401) } // Handle other error responses if (!response.ok) { const error = await this.parseErrorResponse(response) throw error } // Parse successful response const data = await response.json() return this.transformResponse(data) } catch (error: any) { clearTimeout(timeoutId) // Handle abort if (error.name === 'AbortError') { throw new AdapterError('Request timeout', 0) } // Re-throw adapter errors if (error instanceof AdapterError) { throw error } // Normalize other errors throw normalizeError(error) } } private buildUrl(path: string, params?: Record): string { const url = new URL(`${API_BASE}${path}`) if (params) { Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { if (Array.isArray(value)) { value.forEach(v => url.searchParams.append(key, String(v))) } else { url.searchParams.set(key, String(value)) } } }) } return url.toString() } private prepareBody(body: any): BodyInit | null { if (body === null || body === undefined) { return null } if (typeof body === 'object' && !(body instanceof FormData)) { return JSON.stringify(this.transformRequest(body)) } return body as BodyInit } private async parseErrorResponse(response: Response): Promise { try { const data = await response.json() return new AdapterError( data.error || response.statusText, response.status, data.details ) } catch { return new AdapterError(response.statusText, response.status) } } // Override in subclasses for custom transformations protected transformRequest(data: any): any { // Convert camelCase to snake_case return this.toSnakeCase(data) } protected transformResponse(data: any): T { // Convert snake_case to camelCase return this.toCamelCase(data) as T } // Helper methods for case conversion private toSnakeCase(obj: any): any { if (obj === null || obj === undefined) return obj if (obj instanceof Date) return obj.toISOString() if (typeof obj !== 'object') return obj if (Array.isArray(obj)) return obj.map(v => this.toSnakeCase(v)) const converted: any = {} for (const [key, value] of Object.entries(obj)) { const snakeKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`) converted[snakeKey] = this.toSnakeCase(value) } return converted } private toCamelCase(obj: any): any { if (obj === null || obj === undefined) return obj if (typeof obj !== 'object') return obj if (Array.isArray(obj)) return obj.map(v => this.toCamelCase(v)) const converted: any = {} for (const [key, value] of Object.entries(obj)) { const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) converted[camelKey] = this.toCamelCase(value) } return converted } } ``` ### Phase 3: Update Auth Endpoints #### 3.1 Login Endpoint `/src/routes/auth/login/+server.ts`: ```typescript import { json } from '@sveltejs/kit' import type { RequestHandler } from './$types' import { z } from 'zod' import { setRefreshCookie } from '$lib/auth/cookies' const LoginSchema = z.object({ email: z.string().email(), password: z.string().min(8) }) export const POST: RequestHandler = async ({ request, cookies, url, fetch }) => { const body = await request.json().catch(() => ({})) const parsed = LoginSchema.safeParse(body) if (!parsed.success) { return json({ error: 'Invalid credentials' }, { status: 400 }) } try { // Call Rails OAuth endpoint const response = await fetch('http://localhost:3000/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...parsed.data, grant_type: 'password', client_id: process.env.OAUTH_CLIENT_ID, client_secret: process.env.OAUTH_CLIENT_SECRET }) }) if (!response.ok) { return json({ error: 'Invalid credentials' }, { status: 401 }) } const oauth = await response.json() // Store refresh token in httpOnly cookie setRefreshCookie(cookies, oauth.refresh_token, { secure: url.protocol === 'https:', maxAge: 60 * 60 * 24 * 30 // 30 days }) // Return access token and user info to client return json({ access_token: oauth.access_token, user: oauth.user, expires_in: oauth.expires_in }) } catch (error) { console.error('Login failed:', error) return json({ error: 'Login failed' }, { status: 500 }) } } ``` #### 3.2 Refresh Endpoint `/src/routes/auth/refresh/+server.ts`: ```typescript import { json } from '@sveltejs/kit' import type { RequestHandler } from './$types' import { REFRESH_COOKIE, setRefreshCookie } from '$lib/auth/cookies' export const POST: RequestHandler = async ({ cookies, url, fetch }) => { const refreshToken = cookies.get(REFRESH_COOKIE) if (!refreshToken) { return json({ error: 'No refresh token' }, { status: 401 }) } try { const response = await fetch('http://localhost:3000/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: process.env.OAUTH_CLIENT_ID, client_secret: process.env.OAUTH_CLIENT_SECRET }) }) if (!response.ok) { // Clear invalid refresh token cookies.delete(REFRESH_COOKIE, { path: '/' }) return json({ error: 'Invalid refresh token' }, { status: 401 }) } const oauth = await response.json() // Update refresh token (rotation) if (oauth.refresh_token) { setRefreshCookie(cookies, oauth.refresh_token, { secure: url.protocol === 'https:', maxAge: 60 * 60 * 24 * 30 }) } return json({ access_token: oauth.access_token, user: oauth.user, expires_in: oauth.expires_in }) } catch (error) { console.error('Refresh failed:', error) return json({ error: 'Refresh failed' }, { status: 500 }) } } ``` #### 3.3 Logout Endpoint `/src/routes/auth/logout/+server.ts`: ```typescript import { json } from '@sveltejs/kit' import type { RequestHandler } from './$types' import { REFRESH_COOKIE } from '$lib/auth/cookies' export const POST: RequestHandler = async ({ cookies, fetch }) => { const refreshToken = cookies.get(REFRESH_COOKIE) // Revoke token on Rails side if (refreshToken) { try { await fetch('http://localhost:3000/oauth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: refreshToken, token_type_hint: 'refresh_token', client_id: process.env.OAUTH_CLIENT_ID, client_secret: process.env.OAUTH_CLIENT_SECRET }) }) } catch (error) { console.error('Token revocation failed:', error) } } // Clear cookie regardless cookies.delete(REFRESH_COOKIE, { path: '/' }) return json({ success: true }) } ``` ### Phase 4: Fix Grid Adapter #### 4.1 Corrected Grid Adapter Methods `/src/lib/api/adapters/grid.adapter.ts` (key fixes): ```typescript // Fix DELETE to include ID async deleteWeapon(id: string): Promise { return this.request(`/grid_weapons/${id}`, { method: 'DELETE' }) } // Fix position update URL async updateWeaponPosition(params: UpdatePositionParams): Promise { const { id, position, container } = params return this.request(`/grid_weapons/${id}/update_position`, { method: 'POST', body: { position, container } }) } // Fix swap URL (no partyId in path) async swapWeapons(params: SwapPositionsParams): Promise<{ source: GridWeapon target: GridWeapon }> { return this.request('/grid_weapons/swap', { method: 'POST', body: params }) } // Apply same patterns to characters and summons... ``` ### Phase 5: Rails Configuration #### 5.1 Update CORS Configuration `config/initializers/cors.rb`: ```ruby Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins( Rails.env.production? ? ['https://app.hensei.dev', 'https://hensei.dev'] : ['http://localhost:5173', 'http://localhost:5174', 'http://127.0.0.1:5173'] ) resource '/api/*', headers: %w[Accept Authorization Content-Type X-Edit-Key], expose: %w[X-RateLimit-Limit X-RateLimit-Remaining X-RateLimit-Reset], methods: %i[get post put patch delete options head], credentials: true, max_age: 86400 end end ``` #### 5.2 Add Bootstrap Endpoint `app/controllers/api/v1/auth_controller.rb`: ```ruby def bootstrap # This is called by hooks.server.ts with refresh token in cookie refresh_token = cookies[:refresh_token] if refresh_token.blank? render json: { error: 'No refresh token' }, status: :unauthorized return end # Use Doorkeeper to validate and exchange token = Doorkeeper::AccessToken.by_refresh_token(refresh_token) if token.nil? || token.revoked? render json: { error: 'Invalid refresh token' }, status: :unauthorized return end # Create new access token new_token = Doorkeeper::AccessToken.create!( application: token.application, resource_owner_id: token.resource_owner_id, scopes: token.scopes, expires_in: 900, # 15 minutes use_refresh_token: false ) user = User.find(new_token.resource_owner_id) render json: { access_token: new_token.token, user: UserBlueprint.render_as_hash(user, view: :auth), expires_in: new_token.expires_in } end ``` ### Phase 6: Add TanStack Query #### 6.1 Install Dependencies ```bash pnpm add @tanstack/svelte-query ``` #### 6.2 Setup Query Client `/src/lib/query/client.ts`: ```typescript import { QueryClient } from '@tanstack/svelte-query' export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 minutes cacheTime: 1000 * 60 * 10, // 10 minutes retry: (failureCount, error: any) => { if (error?.status === 401) return false return failureCount < 3 }, refetchOnWindowFocus: false }, mutations: { retry: false } } }) ``` #### 6.3 Use in Components ```svelte {#if $partyQuery.isLoading} {:else if $partyQuery.error} {:else if $partyQuery.data} {/if} ``` ## Migration Timeline ### Day 0: Preparation - [ ] Backup current state - [ ] Review Rails CORS configuration - [ ] Setup feature flags ### Day 1: Core Authentication - [ ] Implement auth store with refresh logic - [ ] Add hooks.server.ts and handleFetch - [ ] Update layout server/client - [ ] Create auth endpoints (login, refresh, logout) - [ ] Test SSR token bootstrap ### Day 2: Adapter Updates - [ ] Update BaseAdapter with smart refresh - [ ] Fix GridAdapter URLs and methods - [ ] Update adapter configuration - [ ] Add TanStack Query - [ ] Test with one adapter (PartyAdapter) ### Day 3: Complete Migration - [ ] Update all remaining adapters - [ ] Update all components to use adapters - [ ] Remove all proxy endpoints - [ ] Test all operations ### Day 4: Hardening & Cleanup - [ ] Add CSP headers - [ ] Configure token TTLs - [ ] Add request timeouts - [ ] Performance testing - [ ] Documentation ## Testing Strategy ### Unit Tests ```typescript // Test auth store refresh logic test('refreshes token when expired', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ access_token: 'new_token', expires_in: 900 }) }) authStore.setAuth('old_token', user, new Date(Date.now() - 1000)) const token = await authStore.getToken() expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('/auth/refresh'), expect.objectContaining({ method: 'POST' }) ) }) ``` ### E2E Tests (Playwright) ```typescript test('grid operations work with auth', async ({ page }) => { // Login await page.goto('/login') await page.fill('[name=email]', 'test@example.com') await page.fill('[name=password]', 'password') await page.click('button[type=submit]') // Navigate to party await page.goto('/teams/test-party') // Test grid operations await page.click('[data-testid=add-weapon]') await expect(page.locator('.weapon-grid')).toContainText('New Weapon') }) ``` ## Security Checklist - [ ] **CSP Headers**: Strict Content Security Policy - [ ] **Token TTL**: 15-minute access tokens - [ ] **Refresh Rotation**: New refresh token on each use - [ ] **Revocation**: Proper logout with token revocation - [ ] **CORS**: Explicit origins, no wildcards - [ ] **HTTPS**: Secure cookies in production - [ ] **XSS Protection**: No token in localStorage - [ ] **CSRF**: Not needed with Bearer tokens ## Success Metrics 1. **No 401/404 Errors**: All API calls succeed 2. **SSR Works**: Server-rendered pages have data 3. **Fast Refresh**: < 100ms token refresh 4. **No Token Leaks**: Tokens not in localStorage/sessionStorage 5. **Performance**: 20% reduction in API latency ## Rollback Plan If issues arise: 1. **Feature Flag**: Toggle `USE_DIRECT_API` env var 2. **Restore Proxies**: Git revert removal commit 3. **Switch Adapters**: Conditional logic in config.ts 4. **Monitor**: Check error rates in Sentry --- *Document Version: 2.0* *Updated with comprehensive token lifecycle, SSR support, and security improvements* *Ready for Production Implementation*