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.

Topology

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.

API client construction

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

ClientbaseURL sourceAuth header behavior
brainREACT_APP_BRAIN_URLAdds Authorization: Bearer <Clerk JWT> when a session exists
backendREACT_APP_BACKEND_URLNo 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.

Auth model on the wire

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.

Brain — query/mutation surfaces

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:

MethodPathUsed byNotes
GET/usersuseUsersQuery (UserAdminPage)Returns { users: UserResponseDto[] }
GET/users/meuseCurrentUserQuery (App, PrivateRoute)5-minute staleTime
PATCH/users/meself-profile update (updateProfile, apps/fennec/src/lib/backend/user.ts:86)Body: { first_name?, last_name? }
PATCH/users/:userId/roleadmin 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.

Backend (embedded Strapi) — API surface

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.

Direct R2 / pre-signed uploads

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.

Fennec-internal “API”

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.

Failure semantics

  • 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).