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" --> clerkThe 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):
| 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/jsonAccess-Control-Request-Method: <METHOD>Access-Control-Request-Headers: Content-Type, AuthorizationContent-Type: application/json(skipped forFormDataso 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:
- Brain is the only target that receives the bearer.
apiClient.tsguardsheaders.Authorization = ...withbackend === "brain". Calls to the embedded Strapi backend are cookie/CORS-based. - Authorization (RBAC) is enforced client-side at the router by
apps/fennec/src/components/PrivateRoute.tsx, gated against the role returned byGET /users/meon 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:
| 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.
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
axiosrejections bubble unmodified to TanStack Queryerrorstate.- Auth failures (
401) on/users/mesurface asisErrorinuseCurrentUserQuery;PrivateRouteredirects the user to/profilewhen 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 — seelib/context/ToastContext).