hensei-web/docs/direct-api-architecture-plan-v2.md

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

  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