WIP - BFF

This commit is contained in:
Francisco Gaona
2026-02-04 00:21:06 +01:00
parent f68321c802
commit 0e2f3dddbc
17 changed files with 645 additions and 254 deletions

View File

@@ -0,0 +1,111 @@
import { defineEventHandler, getMethod, readBody, getQuery, createError, getHeader } from 'h3'
import { getSubdomainFromRequest } from '~/server/utils/tenant'
import { getSessionToken } from '~/server/utils/session'
/**
* Catch-all API proxy that forwards requests to the NestJS backend
* Injects x-tenant-subdomain header and Authorization from HTTP-only cookie
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const method = getMethod(event)
const path = event.context.params?.path || ''
// Get subdomain and session token
const subdomain = getSubdomainFromRequest(event)
const token = getSessionToken(event)
const backendUrl = config.backendUrl || 'http://localhost:3000'
// Build the full URL with query parameters
const query = getQuery(event)
const queryString = new URLSearchParams(query as Record<string, string>).toString()
const fullUrl = `${backendUrl}/api/${path}${queryString ? `?${queryString}` : ''}`
// Build headers to forward
const headers: Record<string, string> = {
'Content-Type': getHeader(event, 'content-type') || 'application/json',
}
// Add subdomain header for backend tenant resolution
if (subdomain) {
headers['x-tenant-subdomain'] = subdomain
}
// Add auth token from HTTP-only cookie
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
// Forward additional headers that might be needed
const acceptHeader = getHeader(event, 'accept')
if (acceptHeader) {
headers['Accept'] = acceptHeader
}
try {
// Prepare fetch options
const fetchOptions: RequestInit = {
method,
headers,
}
// Add body for methods that support it
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const body = await readBody(event)
if (body) {
fetchOptions.body = JSON.stringify(body)
}
}
// Make request to backend
const response = await fetch(fullUrl, fetchOptions)
// Handle non-JSON responses (like file downloads)
const contentType = response.headers.get('content-type')
if (!response.ok) {
// Try to get error details
let errorMessage = `Backend error: ${response.status}`
let errorData = null
try {
errorData = await response.json()
errorMessage = errorData.message || errorMessage
} catch {
// Response wasn't JSON
}
throw createError({
statusCode: response.status,
statusMessage: errorMessage,
data: errorData,
})
}
// Return empty response for 204 No Content
if (response.status === 204) {
return null
}
// Handle JSON responses
if (contentType?.includes('application/json')) {
return await response.json()
}
// Handle other content types (text, etc.)
return await response.text()
} catch (error: any) {
// Re-throw H3 errors
if (error.statusCode) {
throw error
}
console.error(`Proxy error for ${method} /api/${path}:`, error)
throw createError({
statusCode: 502,
statusMessage: 'Failed to connect to backend service',
})
}
})

View File

@@ -0,0 +1,81 @@
import { defineEventHandler, readBody, createError } from 'h3'
import { getSubdomainFromRequest, isCentralSubdomain } from '~/server/utils/tenant'
import { setSessionCookie, setTenantIdCookie } from '~/server/utils/session'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const body = await readBody(event)
// Extract subdomain from the request
const subdomain = getSubdomainFromRequest(event)
if (!subdomain) {
throw createError({
statusCode: 400,
statusMessage: 'Unable to determine tenant from subdomain',
})
}
// Determine the backend URL based on whether this is central admin or tenant
const isCentral = isCentralSubdomain(subdomain)
const backendUrl = config.backendUrl || 'http://localhost:3000'
const loginEndpoint = isCentral ? '/api/auth/central/login' : '/api/auth/login'
try {
// Forward login request to NestJS backend with subdomain header
const response = await fetch(`${backendUrl}${loginEndpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-tenant-subdomain': subdomain,
},
body: JSON.stringify(body),
})
const data = await response.json()
if (!response.ok) {
throw createError({
statusCode: response.status,
statusMessage: data.message || 'Login failed',
data: data,
})
}
// Extract token and tenant info from response
const { access_token, user, tenantId } = data
if (!access_token) {
throw createError({
statusCode: 500,
statusMessage: 'No access token received from backend',
})
}
// Set HTTP-only cookie with the JWT token
setSessionCookie(event, access_token)
// Set tenant ID cookie (readable by client for context)
if (tenantId) {
setTenantIdCookie(event, tenantId)
}
// Return user info (but NOT the token - it's in HTTP-only cookie)
return {
success: true,
user,
tenantId,
}
} catch (error: any) {
// Re-throw H3 errors
if (error.statusCode) {
throw error
}
console.error('Login proxy error:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to connect to authentication service',
})
}
})

View File

@@ -0,0 +1,37 @@
import { defineEventHandler, createError } from 'h3'
import { getSubdomainFromRequest } from '~/server/utils/tenant'
import { getSessionToken, clearSessionCookie, clearTenantIdCookie } from '~/server/utils/session'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const subdomain = getSubdomainFromRequest(event)
const token = getSessionToken(event)
const backendUrl = config.backendUrl || 'http://localhost:3000'
try {
// Call backend logout endpoint if we have a token
if (token) {
await fetch(`${backendUrl}/api/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...(subdomain && { 'x-tenant-subdomain': subdomain }),
},
})
}
} catch (error) {
// Log but don't fail - we still want to clear cookies
console.error('Backend logout error:', error)
}
// Always clear cookies regardless of backend response
clearSessionCookie(event)
clearTenantIdCookie(event)
return {
success: true,
message: 'Logged out successfully',
}
})

View File

@@ -0,0 +1,60 @@
import { defineEventHandler, createError } from 'h3'
import { getSubdomainFromRequest } from '~/server/utils/tenant'
import { getSessionToken } from '~/server/utils/session'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const subdomain = getSubdomainFromRequest(event)
const token = getSessionToken(event)
if (!token) {
throw createError({
statusCode: 401,
statusMessage: 'Not authenticated',
})
}
const backendUrl = config.backendUrl || 'http://localhost:3000'
try {
// Fetch current user from backend
const response = await fetch(`${backendUrl}/api/auth/me`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...(subdomain && { 'x-tenant-subdomain': subdomain }),
},
})
if (!response.ok) {
if (response.status === 401) {
throw createError({
statusCode: 401,
statusMessage: 'Session expired',
})
}
throw createError({
statusCode: response.status,
statusMessage: 'Failed to fetch user',
})
}
const user = await response.json()
return {
authenticated: true,
user,
}
} catch (error: any) {
if (error.statusCode) {
throw error
}
console.error('Auth check error:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to verify authentication',
})
}
})

View File

@@ -0,0 +1,75 @@
import type { H3Event } from 'h3'
import { getCookie, setCookie, deleteCookie } from 'h3'
const SESSION_COOKIE_NAME = 'routebox_session'
const SESSION_MAX_AGE = 60 * 60 * 24 * 7 // 7 days
export interface SessionData {
token: string
tenantId: string
userId: string
email: string
}
/**
* Get the session token from HTTP-only cookie
*/
export function getSessionToken(event: H3Event): string | null {
return getCookie(event, SESSION_COOKIE_NAME) || null
}
/**
* Set the session token in an HTTP-only cookie
*/
export function setSessionCookie(event: H3Event, token: string): void {
const isProduction = process.env.NODE_ENV === 'production'
setCookie(event, SESSION_COOKIE_NAME, token, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: SESSION_MAX_AGE,
path: '/',
})
}
/**
* Clear the session cookie
*/
export function clearSessionCookie(event: H3Event): void {
deleteCookie(event, SESSION_COOKIE_NAME, {
path: '/',
})
}
/**
* Get tenant ID from a separate cookie (for SSR access)
* This is NOT the auth token - just tenant context
*/
export function getTenantIdFromCookie(event: H3Event): string | null {
return getCookie(event, 'routebox_tenant') || null
}
/**
* Set tenant ID cookie (readable by client for context)
*/
export function setTenantIdCookie(event: H3Event, tenantId: string): void {
const isProduction = process.env.NODE_ENV === 'production'
setCookie(event, 'routebox_tenant', tenantId, {
httpOnly: false, // Allow client to read tenant context
secure: isProduction,
sameSite: 'lax',
maxAge: SESSION_MAX_AGE,
path: '/',
})
}
/**
* Clear tenant ID cookie
*/
export function clearTenantIdCookie(event: H3Event): void {
deleteCookie(event, 'routebox_tenant', {
path: '/',
})
}

View File

@@ -0,0 +1,39 @@
import type { H3Event } from 'h3'
import { getHeader } from 'h3'
/**
* Extract subdomain from the request Host header
* Handles production domains (tenant1.routebox.co) and development (tenant1.localhost)
*/
export function getSubdomainFromRequest(event: H3Event): string | null {
const host = getHeader(event, 'host') || ''
const hostname = host.split(':')[0] // Remove port if present
const parts = hostname.split('.')
// For production domains with 3+ parts (e.g., tenant1.routebox.co)
if (parts.length >= 3) {
const subdomain = parts[0]
// Ignore www subdomain
if (subdomain === 'www') {
return null
}
return subdomain
}
// For development (e.g., tenant1.localhost)
if (parts.length === 2 && parts[1] === 'localhost') {
return parts[0]
}
return null
}
/**
* Check if the subdomain is a central/admin subdomain
*/
export function isCentralSubdomain(subdomain: string | null): boolean {
if (!subdomain) return false
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',')
return centralSubdomains.includes(subdomain)
}