Skip to content

The React SPA and the Typed API Framework

This doc covers the user-facing web app and the API layer it talks to. It replaced the earlier server-rendered Django-templates + HTMX product UI. Legacy Django-rendered HTML now survives only for the operator admin tools (/cc_admin/), the first-run setup wizard, and allauth pages.

The two halves

  • Frontend: a React 19 + TypeScript + Vite single-page app in campuscore_app/web/.
  • Backend: a typed JSON/SSE API defined by a small decorator framework in campuscore_app/campus_core/api/, with endpoint modules in campuscore_app/apps/main_app/apis/*_api.py.

The contract between them is a generated one: the backend emits an OpenAPI document, and the frontend generates its TypeScript types from it. A Pydantic model change becomes a TypeScript compile error, and CI fails if the two ever drift.

Frontend (campuscore_app/web/)

Stack and conventions:

  • Server state is owned by TanStack Query, with a per-module key factory (conversationKeys, folderKeys, fileKeys, ...). Never duplicate server data into local component state.
  • UI state (drawer open, panel width) lives in a small Zustand store (src/lib/uiStore.ts) and nothing else.
  • Routing is react-router in data mode (src/routes/router.tsx). There is no SSR server; the router runs entirely in the browser and Django serves the same shell for every SPA path.
  • Generated types live in src/types/generated/api.ts and are reachable only through the single door src/lib/api/schema.ts. An ESLint no-restricted-imports rule forbids importing the generated file directly, so a schema rename is a one-file change.
  • TypeScript is strict (strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes); no-explicit-any is an error.

Directory shape under src/:

  • routes/ - one component per route, plus RequireAuth (the gate every route sits behind) and the router config.
  • components/ - chat/, panel/, settings/, knowledge/, layout/, ui/ (shared primitives).
  • lib/api/ - one module per resource; each wraps api (the fetch client) and exposes typed hooks.
  • lib/streaming/ - the SSE client for the agent chat stream.
  • lib/upload/ - direct-to-S3 upload (presign -> PUT -> confirm) and its hooks.
  • lib/render/ - DOMPurify sanitization, safeHref, and the HTML-to-text helper for copy.

Route-param gotcha (read before "fixing" a layout component)

AppShell is a pathless layout route that renders ConversationSidebar and ConversationPanel as siblings of <Outlet/>. Both read the active conversation id with useMatch("/chat/:chatId"), not useParams(). useParams() would in fact also work at that depth - React Router shares one params object across every match in a branch - but useMatch states the intent ("which chat route is open") and returns null off /chat/:chatId without a separate check. Do not "fix" a layout component that reads a child route's param on the assumption that it must be broken; verify in the browser first.

Backend API framework (campus_core/api/)

An endpoint is an ordinary function wrapped by one decorator:

@api_endpoint(methods=["POST"], input=CreateFolderInput, output=FolderSummary, auth="workspace_admin")
def create_folder(request, data: CreateFolderInput) -> FolderSummary:
    ...

The decorator (decorators.py) does five things:

  1. Applies the auth guard named by auth= before the body is parsed. Levels are session (default), workspace_admin, superuser, public_access, and none (explicit, greppable opt-out). The guard runs through sync_to_async on async views, so an async endpoint enforces the same level as a sync one.
  2. Parses the request into the declared Pydantic input model (JSON body for POST/PUT/PATCH, query params for GET), raising a 422 with field-level, PII-free detail on failure.
  3. Serializes the return value from the declared Pydantic output model. A view may return a raw HttpResponseBase (file download, redirect) as an escape hatch.
  4. Maps every error to one envelope - {"error": {"code", "message", "details?}} - and never lets an exception escape as an HTML 500. ApiError subclasses carry their status (ApiNotFoundError 404, ApiConflictError 409, ApiRateLimitError 429, ...). A builtin PermissionError with a user-facing message maps to 403; one with an OS errno is treated as a bug and returns a generic 500.
  5. Self-registers in registry.py so schema.py can emit OpenAPI without a second declaration.

Extras:

  • Rate limiting is declarative: rate_limit=RateLimit(...) on the decorator, enforced after auth, backed by the shared Redis cache, keyed by the authenticated user (never a spoofable header), and fail-open on a cache outage.
  • Streaming uses @api_streaming_endpoint: the view is an async def yielding SseEvents. A failure before the first frame becomes a JSON error; a pre-frame ApiError raised inside the generator surfaces its real message in the terminal SSE error frame. See streaming.py for the disconnect/heartbeat/single-task-pump behavior.
  • Input models forbid unknown fields (ApiInput sets extra="forbid"), closing mass assignment on every mutation.
  • Object-level authorization lives in the service layer, not the view: owner-scoped lookups return 404 for another user's id rather than confirming it exists.

The contract pipeline

Pydantic input/output models  ->  manage.py export_openapi  ->  openapi.json
openapi.json  ->  openapi-typescript  ->  web/src/types/generated/api.ts
  • Regenerate after changing any input=/output= model (or a contract model they reference) with scripts/sync_openapi_types.sh.
  • Both openapi.json and api.ts are committed generated artifacts.
  • .github/workflows/pr-validation.yml runs an unconditional drift gate: it re-exports the schema and regenerates the types, and fails the PR if either differs from what was committed. A local pre-commit hook does the same, but CI is the enforcement that cannot be skipped.

Enumerated fields

A status, or any other closed set of values, on a wire model is typed with the model's TextChoices class (status: SourceFileStatus), or with a module-level Literal alias when there is no model behind it (DoneStatus, ViewerKind, SslMode). Never str: a bare string reaches the SPA as string, and the SPA then keeps its own copy of the allowed values, which drifts. The generator emits a named enum component for a TextChoices field and an inline enum for a Literal, web/src/lib/api/schema.ts re-exports each as a named union, and every SPA branch on the value is an exhaustive switch. A client-owned union keeps assertNever in its default; a value the server sent uses the non-throwing form, const unhandled: never = status; void unhandled; return <fallback>;, so a newer server never turns into a render crash in a browser holding the previous bundle. apps/main_app/tests/test_api_status_types.py sweeps the exported document and fails on any bare-string property named status, kind, protocol, scope, match_mode, actor_type, category or outcome, in a component or in a query parameter. Add a name to that list when a new field joins the family.

When every reference to a TextChoices field moves inside a union's discriminators, the named enum component disappears from the document, because nothing refers to it any more. schema.ts then aliases the status from the union instead of from the component - CustomDomainStatus = DomainRow["status"] - which is the same set of literals, and the union's own coverage test takes over from the component's.

The same typing on an input= model is the whole validation: the framework answers an unknown value with a 422 naming the field, before any handler code runs, so no service re-checks the value and no handler holds a fallback for one that never arrives. A column behind such a field carries a models.CheckConstraint on its values, which is what makes the API's parse of a stored value a proof rather than a coercion.

In the SPA a value set may appear in exactly one shape the compiler checks: a Record<Union, ...> keyed by the generated union, or the exhaustive switch above. A hand-written array of the members, or a literal compared against a value typed string, is the drift this rule exists to prevent; comparing a literal against the generated union (connector.kind === "oauth2") is fine, because tsc rejects a member the server no longer sends. When the UI needs to render the members, the server sends the list of choices with their labels.

A status with companion fields whose row carries check constraints is a per-status union rather than a flat model: one variant per status, status: Literal[<Enum>.<MEMBER>], joined with Field(discriminator="status"), and wrapped in a RootModel when it is a top-level output=. PendingActionResult, ConnectionRow, BatchProgress, FileItem, FileStatus and DomainRow are the pattern: only a failed action carries an error_message, only an expired connection says why, only a failed batch has a failure_reason, only a failed file has an error, only a failed domain says why provisioning gave up, and the serializer raises on a row that contradicts its own status because Pydantic would otherwise drop the field silently.

The same split applies to any discriminator, not only status. FeatureItem is joined on scope: UserScopedFeature carries the roles a feature is scoped to and DeploymentScopedFeature carries none, so the SPA cannot read a role scope off a feature that has none. MessageOut and GuestMessage are joined on role: a UserMessage carries is_edited and its attachments, an AssistantMessage carries was_interrupted, its rating and its trace, and the row's own check constraints are what say those combinations are the only ones.

An output model can also be a projection of a stored one: TranscriptTrace (services/chat/turn_trace.py) is the transcript's view of PersistedTurnTrace, the same fields over the wire event classes, so the stored data_preview never reaches the schema while the stored form keeps it.

Serving the SPA

The bundle is a static asset, not a second service (campus_core/spa.py):

  • Vite builds into static/spa/; collectstatic bakes it into STATIC_ROOT at image build time; WhiteNoise serves it.
  • index.html is a Django template so it can set the CSRF cookie the SPA reads for its X-CSRFToken header. CSRF is not exempted; API requests flow through DynamicCsrfViewMiddleware like any other.
  • spa_shell is a catch-all mounted last that returns the shell for any SPA route but 404s the /static/, /media/, /api/, and /_allauth/ prefixes, so a missing chunk during a rolling deploy fails cleanly instead of returning HTML where JavaScript was expected.
  • Locally, scripts/build_spa_local.sh mirrors the Dockerfile's node stage to refresh the Django-served bundle; the Vite dev server on :5173 serves source live and shares the localhost session cookie.

Where to look

  • Framework: campus_core/api/{decorators,auth,responses,exceptions,pagination,registry,schema,streaming,rate_limit}.py.
  • Endpoints: apps/main_app/apis/*_api.py (singular _api.py = typed SPA endpoints; *_apis.py = the remaining /cc_admin/ HTMX admin tools).
  • Frontend entry: web/src/main.tsx -> router.tsx -> RequireAuth -> AppShell.
  • Contract: apps/main_app/management/commands/export_openapi.py, scripts/sync_openapi_types.sh, openapi.json, web/src/lib/api/schema.ts.