Skip to content

Fennec API Surface

Fennec is the React 19 / Vite admin dashboard. It does not expose its own HTTP API — it is a single-page app served as static assets. All data flows are calls out to upstream services (brain and a backend referred to as backend in code, which is the embedded Strapi 5 instance under apps/fennec/backend/).

This document covers the wire-level contract between fennec and its upstreams: how requests are shaped, how auth is attached, and what the SPA expects back.

flowchart LR
    user[Operator browser]
    spa[fennec SPA<br/>static dist]
    clerk[Clerk]
    brain[brain<br/>NestJS]
    strapi[fennec/backend<br/>Strapi 5]

    user -- HTTPS --> spa
    spa -- "Bearer JWT" --> brain
    spa -- "withCredentials cookie<br/>+ FENNEC_API_TOKEN" --> strapi
    spa -- "session token" --> clerk

The SPA shell mounts ClerkProvider (apps/fennec/src/index.tsx) and routes through React Router v7. All upstream HTTP traffic is mediated by the axios wrapper in apps/fennec/src/lib/backend/apiClient.ts.

apps/fennec/src/lib/backend/apiClient.ts exports two clients via createApiClient(backend):

Client baseURL source Auth header behavior
brain REACT_APP_BRAIN_URL Adds Authorization: Bearer <Clerk JWT> when a session exists
backend REACT_APP_BACKEND_URL No bearer; relies on withCredentials: true cookies. The Authorization header is attached only when backend === "brain" (apps/fennec/src/lib/backend/apiClient.ts:47-49). REACT_APP_FENNEC_API_TOKEN is committed to apps/fennec/.env but is not referenced anywhere in apps/fennec/src/**; it is dead on the SPA.

Both clients always set:

  • Accept: application/json
  • Access-Control-Request-Method: <METHOD>
  • Access-Control-Request-Headers: Content-Type, Authorization
  • Content-Type: application/json (skipped for FormData so the browser attaches the multipart boundary)
  • withCredentials: true (CORS cookie passthrough)

Tokens are pulled lazily per request from window.Clerk.session.getToken() (apps/fennec/src/lib/auth/clerk.ts). If Clerk has not loaded, the helper returns "" and the Authorization header is omitted.

In dev (import.meta.env.DEV), each request is logged to the console with method, URL, and FormData flag.

Fennec aligns with the platform Clerk-based auth model documented in standards/auth-model.md. Two notes specific to fennec:

  1. Brain is the only target that receives the bearer. apiClient.ts guards headers.Authorization = ... with backend === "brain". Calls to the embedded Strapi backend are cookie/CORS-based.
  2. Authorization (RBAC) is enforced client-side at the router by apps/fennec/src/components/PrivateRoute.tsx, gated against the role returned by GET /users/me on brain (see below). Server-side authorization is brain’s responsibility — fennec only filters UI affordances.

Roles (apps/fennec/src/lib/auth/roles.ts) form a strict hierarchy: USER < CREATOR < MODERATOR < ADMIN < ROOT. isRoleOrAbove(role, minRole) is the comparator used by PrivateRoute and the useHasRoleOrAbove / useIsAdminUser hooks.

Fennec hits brain through brainApi (a createApiClient("brain") instance re-exported from apps/fennec/src/lib/backend/apiClient.ts). All endpoints are REST under the brain BACKEND_URL and require a valid Clerk JWT.

The user surface is canonical and documented:

Method Path Used by Notes
GET /users useUsersQuery (UserAdminPage) Returns { users: UserResponseDto[] }
GET /users/me useCurrentUserQuery (App, PrivateRoute) 5-minute staleTime
PATCH /users/me self-profile update (updateProfile, apps/fennec/src/lib/backend/user.ts:86) Body: { first_name?, last_name? }
PATCH /users/:userId/role admin role mutation (updateUserRole, apps/fennec/src/lib/backend/user.ts:108-111) Body: { role }

Other surfaces (presets, prompts, motion types, characters, generations, example gallery, moderation review, kitsune dataset, etc.) follow the same shape: query keys live next to the consuming page in src/pages/** or src/lib/backend/**. Conventions used throughout:

  • TanStack Query v5 (@tanstack/react-query@^5.94) for caching and retries.
  • DTO → domain mappers (mapUserDtoToUser, etc.) live alongside the query hook. snake_case on the wire, camelCase in app state.
  • Mutations call queryClient.invalidateQueries({ queryKey: ... }) on success rather than optimistic updates.

REACT_APP_BACKEND_URL (default http://localhost:9950) points to the Strapi 5 instance under apps/fennec/backend/. It serves admin-only data the SPA needs that does not belong on brain — typically R2 metadata, generation adapter glue, and RunPod proxy calls. All requests use cookie auth via withCredentials: true. The Strapi sources are not committed in this repo (apps/fennec/backend/ ships only node_modules); the route catalogue should be enumerated from apps/fennec/backend/src/api/**/routes/ once that tree is in scope for docs. TODO(@law): commit the Strapi sources or link the canonical route catalog.

Some flows (e.g. apps/fennec/src/lib/backend/brainInputMedia.ts) issue axios.put(upload_url, file, ...) against pre-signed URLs returned from brain. These bypass apiClient entirely — no Authorization header, no withCredentials — because the URL itself carries the signature.

There are no dev/admin-only HTTP routes hosted by fennec. The Vite dev server (vite.config.ts, port 3000) serves the SPA only. There is no vite.config proxy block; CORS is handled by the upstreams.

  • axios rejections bubble unmodified to TanStack Query error state.
  • Auth failures (401) on /users/me surface as isError in useCurrentUserQuery; PrivateRoute redirects the user to /profile when a guarded route is accessed without sufficient role.
  • Network errors during dev are logged via the apiClient pre-flight console.log; no global toast wiring exists at the apiClient layer (toasts are component-local — see lib/context/ToastContext).