22 KiB
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:
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<boolean> | null
}
function createAuthStore() {
const { subscribe, set, update } = writable<AuthState>({
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<boolean> {
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:
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:
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:
<script lang="ts">
import { authStore } from '$lib/stores/auth.store'
import { onMount } from 'svelte'
export let data
// Hydrate auth store on client
onMount(() => {
if (data?.accessToken && data?.user && data?.expiresAt) {
authStore.setAuth(
data.accessToken,
data.user,
new Date(data.expiresAt)
)
}
})
</script>
<slot />
Phase 2: Update Base Adapter with Smart Refresh
2.1 Enhanced Base Adapter
/src/lib/api/adapters/base.adapter.ts:
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<string, any>
}
export abstract class BaseAdapter {
constructor(protected fetcher?: typeof fetch) {}
protected async request<T>(
path: string,
options: RequestOptions = {}
): Promise<T> {
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<T>(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<T>(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, any>): 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<AdapterError> {
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<T>(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:
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:
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:
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):
// Fix DELETE to include ID
async deleteWeapon(id: string): Promise<void> {
return this.request<void>(`/grid_weapons/${id}`, {
method: 'DELETE'
})
}
// Fix position update URL
async updateWeaponPosition(params: UpdatePositionParams): Promise<GridWeapon> {
const { id, position, container } = params
return this.request<GridWeapon>(`/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:
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:
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
pnpm add @tanstack/svelte-query
6.2 Setup Query Client
/src/lib/query/client.ts:
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
<script lang="ts">
import { createQuery, createMutation } from '@tanstack/svelte-query'
import { partyAdapter } from '$lib/api/adapters'
const partyQuery = createQuery({
queryKey: ['party', shortcode],
queryFn: () => partyAdapter.getByShortcode(shortcode),
enabled: !!shortcode
})
const updateMutation = createMutation({
mutationFn: (data) => partyAdapter.update(shortcode, data),
onSuccess: () => {
queryClient.invalidateQueries(['party', shortcode])
}
})
</script>
{#if $partyQuery.isLoading}
<Loading />
{:else if $partyQuery.error}
<Error message={$partyQuery.error.message} />
{:else if $partyQuery.data}
<Party data={$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
// 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)
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
- No 401/404 Errors: All API calls succeed
- SSR Works: Server-rendered pages have data
- Fast Refresh: < 100ms token refresh
- No Token Leaks: Tokens not in localStorage/sessionStorage
- Performance: 20% reduction in API latency
Rollback Plan
If issues arise:
- Feature Flag: Toggle
USE_DIRECT_APIenv var - Restore Proxies: Git revert removal commit
- Switch Adapters: Conditional logic in config.ts
- Monitor: Check error rates in Sentry
Document Version: 2.0 Updated with comprehensive token lifecycle, SSR support, and security improvements Ready for Production Implementation