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 incampuscore_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.tsand are reachable only through the single doorsrc/lib/api/schema.ts. An ESLintno-restricted-importsrule forbids importing the generated file directly, so a schema rename is a one-file change. - TypeScript is strict (
strict,noUncheckedIndexedAccess,exactOptionalPropertyTypes);no-explicit-anyis an error.
Directory shape under src/:
routes/- one component per route, plusRequireAuth(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 wrapsapi(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:
- Applies the auth guard named by
auth=before the body is parsed. Levels aresession(default),workspace_admin,superuser,public_access, andnone(explicit, greppable opt-out). The guard runs throughsync_to_asyncon async views, so an async endpoint enforces the same level as a sync one. - Parses the request into the declared Pydantic
inputmodel (JSON body for POST/PUT/PATCH, query params for GET), raising a 422 with field-level, PII-free detail on failure. - Serializes the return value from the declared Pydantic
outputmodel. A view may return a rawHttpResponseBase(file download, redirect) as an escape hatch. - Maps every error to one envelope -
{"error": {"code", "message", "details?}}- and never lets an exception escape as an HTML 500.ApiErrorsubclasses carry their status (ApiNotFoundError404,ApiConflictError409,ApiRateLimitError429, ...). A builtinPermissionErrorwith a user-facing message maps to 403; one with an OSerrnois treated as a bug and returns a generic 500. - Self-registers in
registry.pysoschema.pycan 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 anasync defyieldingSseEvents. A failure before the first frame becomes a JSON error; a pre-frameApiErrorraised inside the generator surfaces its real message in the terminal SSEerrorframe. Seestreaming.pyfor the disconnect/heartbeat/single-task-pump behavior. - Input models forbid unknown fields (
ApiInputsetsextra="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) withscripts/sync_openapi_types.sh. - Both
openapi.jsonandapi.tsare committed generated artifacts. .github/workflows/pr-validation.ymlruns 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/;collectstaticbakes it intoSTATIC_ROOTat image build time; WhiteNoise serves it. index.htmlis a Django template so it can set the CSRF cookie the SPA reads for itsX-CSRFTokenheader. CSRF is not exempted; API requests flow throughDynamicCsrfViewMiddlewarelike any other.spa_shellis 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.shmirrors the Dockerfile's node stage to refresh the Django-served bundle; the Vite dev server on:5173serves 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.