2026-06-13 09:52:56 +02:00
|
|
|
/**
|
|
|
|
|
* Typed API client built on openapi-fetch + generated schema.d.ts.
|
|
|
|
|
*
|
|
|
|
|
* Middleware contract (orchestrator-decisions.md §11):
|
|
|
|
|
* 1. Always send cookies (credentials: "include"; same-origin auto-sends but explicit is clear).
|
|
|
|
|
* 2. Non-GET/HEAD requests inject X-CSRF-Token from the csrf holder.
|
|
|
|
|
* Exception: POST /api/auth/login skips injection (unauthenticated endpoint).
|
|
|
|
|
* 3. 401 responses → clear session state + navigate to /login.
|
|
|
|
|
* 4. Other non-2xx responses → throw an ApiError carrying the parsed JSON body,
|
|
|
|
|
* so callers (e.g. SMTP test) can inspect body.result.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import createClient, { type Middleware } from 'openapi-fetch'
|
|
|
|
|
import type { paths } from './schema.d.ts'
|
|
|
|
|
import { getCsrfToken } from './csrf'
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Error type
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/** Error thrown for non-2xx, non-401 responses. Carries the parsed JSON body. */
|
|
|
|
|
export class ApiError extends Error {
|
|
|
|
|
constructor(
|
|
|
|
|
public readonly status: number,
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
public readonly body: any,
|
|
|
|
|
) {
|
|
|
|
|
super(`API error ${status}`)
|
|
|
|
|
this.name = 'ApiError'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Internal navigation helper (avoids React-router import at module level)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
let _navigateToLogin: (() => void) | null = null
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Register a callback that the middleware calls on 401.
|
|
|
|
|
* SessionProvider calls this during its setup.
|
|
|
|
|
*/
|
|
|
|
|
export function registerLoginRedirect(fn: () => void): void {
|
|
|
|
|
_navigateToLogin = fn
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// CSRF middleware
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
|
|
|
|
|
const LOGIN_PATH = '/api/auth/login'
|
|
|
|
|
|
2026-06-13 15:20:35 +02:00
|
|
|
/**
|
|
|
|
|
* Endpoints where a 401 is an EXPECTED, locally-handled outcome and must NOT
|
|
|
|
|
* trigger the global login redirect:
|
|
|
|
|
* - GET /api/session — the session probe; 401 means "not logged in", handled
|
|
|
|
|
* by SessionProvider's queryFn (returns null → unauthenticated state).
|
|
|
|
|
* - POST /api/auth/login — bad-credentials check; 401 handled by LoginPage.
|
|
|
|
|
*
|
|
|
|
|
* Redirecting on these would invalidate the session query, which refetches
|
|
|
|
|
* /api/session, which 401s, which redirects again → an infinite loop that
|
|
|
|
|
* floods GET /api/session after logout and on the login page.
|
|
|
|
|
*/
|
|
|
|
|
const SESSION_PATH = '/api/session'
|
|
|
|
|
const NO_REDIRECT_ON_401 = new Set<string>([SESSION_PATH, LOGIN_PATH])
|
|
|
|
|
|
|
|
|
|
export const csrfMiddleware: Middleware = {
|
2026-06-13 09:52:56 +02:00
|
|
|
async onRequest({ request }) {
|
|
|
|
|
// Always include cookies (same-origin; explicit for clarity)
|
|
|
|
|
// Note: credentials is set at client level; this is belt-and-suspenders doc.
|
|
|
|
|
|
|
|
|
|
const method = request.method.toUpperCase()
|
|
|
|
|
const url = new URL(request.url)
|
|
|
|
|
|
|
|
|
|
if (WRITE_METHODS.has(method) && url.pathname !== LOGIN_PATH) {
|
|
|
|
|
const token = getCsrfToken()
|
|
|
|
|
if (token) {
|
|
|
|
|
request.headers.set('X-CSRF-Token', token)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return request
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-13 15:20:35 +02:00
|
|
|
async onResponse({ schemaPath, response }) {
|
2026-06-13 09:52:56 +02:00
|
|
|
if (response.status === 401) {
|
2026-06-13 15:20:35 +02:00
|
|
|
// The session probe and the login endpoint own their 401s (see
|
|
|
|
|
// NO_REDIRECT_ON_401). For any OTHER endpoint, a 401 means the session
|
|
|
|
|
// expired mid-use → redirect to /login. Crucially, NOT redirecting on the
|
|
|
|
|
// session probe breaks the refetch→401→redirect→refetch flood loop.
|
|
|
|
|
if (!NO_REDIRECT_ON_401.has(schemaPath) && _navigateToLogin) {
|
2026-06-13 09:52:56 +02:00
|
|
|
_navigateToLogin()
|
|
|
|
|
}
|
|
|
|
|
// Return the original response so callers can handle 401 if needed.
|
|
|
|
|
return response
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
// Parse body and throw; caller can catch ApiError and read .body
|
|
|
|
|
let body: unknown
|
|
|
|
|
try {
|
|
|
|
|
body = await response.clone().json()
|
|
|
|
|
} catch {
|
|
|
|
|
body = null
|
|
|
|
|
}
|
|
|
|
|
throw new ApiError(response.status, body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Client instance
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const apiClient = createClient<paths>({
|
|
|
|
|
baseUrl: '/',
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
apiClient.use(csrfMiddleware)
|
|
|
|
|
|
|
|
|
|
export default apiClient
|