This repository houses the codebase for the LibreTexts Conductor Platform, which powers:
- The Conductor application itself
- The LibreCommons
- Campus Commons instances
- Campus Conductor instances
- LibreTexts Adoption Reporting
- LibreTexts OER Integration Requests
A single codebase serves every tenant. The boot-required ORG_ID environment variable selects which organization the running instance represents (libretexts for LibreCommons, a campus slug otherwise). server.ts exits immediately if it is missing.
This is a monorepo with two independently built packages. The repository root holds only tooling (Husky, commitlint) and has no source dependencies, so npm install must be run inside client/ and server/ separately.
client/ Vite + React + TypeScript SPA
server/ Express + TypeScript, native ESM
Requires Node 22 and access to a MongoDB instance (Atlas in every deployed environment).
cd server && npm install
cd ../client && npm installCopy client/.env.dist to client/.env and fill it in. The server reads server/.env via dotenv;
Server (from server/):
| Command | What it does |
|---|---|
npm run dev | tsx watch server.ts piped through pino-pretty, hot reload |
npm run typecheck | tsc --noEmit |
npm run build | tsup (ESM bundle + d.ts) then tsc-alias, output to server/dist |
npm run start:prod | Runs the built dist/server.js |
Client (from client/):
| Command | What it does |
|---|---|
npm start | Vite dev server on port 3000, proxying API calls to VITE_DEV_BASE_URL |
npm run build | tsc && vite build, bundling to client/dist |
npm test | Vitest (jsdom + Testing Library) |
npm run test:watch | Vitest in watch mode |
Run a single client test with npm test -- src/path/to/file.test.tsx or npx vitest run -t "test name". There is currently no server test suite.
These apply to all new UI. Large parts of the app predate them, so expect to encounter the older patterns described under Legacy surface area. When you touch legacy code, migrate the piece you are touching rather than extending the old pattern (unless you're only hotfixing).
New components come from @libretexts/davis-react (with @libretexts/davis-react-table for data tables and @libretexts/davis-core for tokens). LLM's can read the docs at https://davis.libretexts.org/llms.txt.
import{Button,Stack,Badge}from"@libretexts/davis-react";import{DataTable}from"@libretexts/davis-react-table";Do not build new UI with Semantic UI React, and do not add local workarounds for upstream Davis bugs. If Davis is missing something or is broken, label the spot as pending a Davis fix and raise it as an issue upstream. All UI must meet WCAG 2.2 AA, which includes fixing inaccessible legacy markup you touch.
Data fetching, caching, and mutations go through @tanstack/react-query. The QueryClient is configured in Platform.tsx and its QueryCache.onError handler surfaces a toast automatically, reading the message from meta.errorMessage when a query provides one. Do not hold server data in Redux or in ad-hoc useState + useEffect fetches.
Colocate hooks with the feature (e.g. components/Home/PinnedProjects/hooks.ts):
exportfunctionusePinnedProjects(){returnuseQuery<User["pinnedProjects"]>({queryKey: ["pinnedProjects"],queryFn: async()=>{constres=awaitapi.getPinnedProjects();if(res.data.err)thrownewError(res.data.errMsg);returnalphabetize(res.data.pinned);},staleTime: 1000*60*5,});}Redux (client/src/state/) remains the home for organization, user, and global UI state only.
client/src/api.ts exports a singleton API class instance. Add a typed method there and call api.<method>() from hooks and components. Do not write axios.get("/some/route") inside a component.
// client/src/api.tsasyncgetFramework(id: string){constres=awaitaxios.get<{framework: AssetTagFramework}&ConductorBaseResponse>(`/assettagframeworks/${id}`);returnres;}The class deliberately does not create its own Axios instance, because not every call site has been migrated yet and the global Axios config in Platform.tsx (base URL, withCredentials, the 401 logout interceptor) still needs to apply everywhere. Routes that bypass Express's default body parser use the private streamJson helper instead of Axios.
Toasts go through useNotifications() from context/NotificationContext, which is backed by the Davis ToastContainer mounted in NotificationsProvider:
const{ addNotification }=useNotifications();addNotification({type: "success",message: "Project updated."});Modals go through useModals() from context/ModalContext. The provider keeps a keyed map of open modals, so you render the modal component into openModal and dismiss it by id (or closeAllModals) rather than threading open/setOpen state through the tree:
const{ openModal, closeModal }=useModals();openModal(<ConfirmModalonCancel={()=>closeModal("confirm-delete")}/>, "confirm-delete");useGlobalError() (components/error/ErrorHooks.ts) dispatches into the Redux error slice, which renders the single global ErrorModal mounted in Platform.tsx. Pass the raw error to handleGlobalError; it unwraps the Conductor response shape (errMsg, errors[], status codes) for you.
const{ handleGlobalError }=useGlobalError();try{awaitapi.updateProject(id,values);}catch(err){handleGlobalError(err);}Use notifications for routine success and low-stakes failure feedback, and useGlobalError when an operation failed in a way the user has to acknowledge.
Request flow: server.ts (Helmet CSP, trust proxy, Mongo connect with retry, static client serving, SPA fallback) mounts the API under /api/v1. Every route is declared in server/api.js, which wires each path to its middleware chain and handler.
The standard chain is authorize → validate → handle.
Validators live in server/api/validators/* and are mounted with middleware.validateZod. Schemas are shaped around the Express request (body, params, query), which lets the handler infer its own request type from the same schema. This is the single source of truth for a route's input contract, so there is no separate interface to keep in sync.
// server/api/validators/store.tsexportconstGetStoreProductSchema=z.object({params: z.object({product_id: z.string().min(1,"Product ID is required"),}),});// server/api.jsrouter.route("/store/checkout/session").post(authAPI.optionalVerifyRequest,authAPI.optionalGetUserAttributes,middleware.validateZod(storeValidators.CreateCheckoutSessionSchema),storeAPI.createCheckoutSession);// server/api/store.tsexportasyncfunctiongetStoreProduct(req: z.infer<typeofGetStoreProductSchema>,res: Response){/* req.params.product_id is typed */}For authenticated routes, use the helpers in server/types/Express.ts (ZodReqWithUser, ZodReqWithOptionalUser, TypedReqWithUser, and friends) so req.user is typed alongside the validated payload. Older routes still use express-validator chains plus middleware.checkValidationErrors; new routes should not. When you touch a route, migrate it to Zod if/when appropriate.
server/api/services/search-service.ts owns a singleton SearchService wrapping the Meilisearch client (MEILISEARCH_URL, MEILISEARCH_API_KEY). Indexes are declared in the INDEXES tuple with their primary keys in INDEX_PRIMARY_KEYS, which keeps addDocuments, search, and getIndexStats tuple-typed against the real index names.
Filters are built through buildFilterString, which accepts plain objects, operator objects ($eq, $in, $gt, $exists, …), logical combinators ($and, $or, $not), or a raw filter string. Do not hand-concatenate Meilisearch filter syntax at call sites.
When a domain writes records that must be searchable, keep the index write next to the domain service (see store-order-search-service.ts called from store-service.ts) rather than scattering index calls through handlers.
All CXOne (MindTouch) interaction goes through the SDK, not hand-rolled Deki requests. server/util/ExpertWithSSM.ts is the singleton that pulls per-library API credentials from AWS SSM Parameter Store and vends a configured Expert client per library subdomain, caching both for 30 minutes:
constexpert=awaitExpertWithSSM.getInstance().forLibrary("chem");If the SDK is missing an endpoint, raise it upstream. Avoid hand-rolling Deki requests in the server code. The SDK is typed, so you get better typesafety and DX.
Library API loops must be throttled to roughly 500ms between per-book requests, using one shared throttle rather than one per library.
server/logger.ts is the only logging entry point. There is no console.* in server code. Bindings object first, human-readable message last, and errors always under err so the stack survives:
logger.info("Sync finished");logger.info({books: 12},"Sync finished");logger.error({ err },"Failed to update project");Tag a subsystem with childLogger("store") instead of prefixing messages with [STORE], because component is a queryable CloudWatch field and a string prefix is not. Inside a request, reqId, method, route, and userUUID are attached automatically by server/request-context.ts; never pass them by hand. The logger redacts common secret keys (password, token, apiKey, cookies, auth headers), but that is a pragmatic net rather than a guarantee, so do not put secrets into bindings.
Levels: fatal for a process going down, error for an operation that failed and needs a human, warn for degraded but self-healing behavior (this is where swallowed nicety and telemetry failures belong), info for lifecycle and notable state changes, debug for verbose flow. LOG_LEVEL overrides the default, which is info in production and debug otherwise.
Domains split three ways:
server/api/store.ts controller: parse the validated request, shape the response
server/api/services/store-service.ts service: business logic and external integrations
server/models/storeorder.ts model: Mongoose schema and persistence
Controllers stay thin. They read from the inferred request type, call one or more services, and return a Conductor response envelope ({ err, message, ...payload }), catching at the boundary with logger.error({ err }, "...") and the helpers in util/errorutils (conductor400Err, conductor404Err, conductor500Err). Services hold the logic and are the only place that talks to third parties (Stripe, Lulu, Slack, Qdrant, OpenAI, central identity). Models own the schema.
Backend services live at server/api/services/, colocated with the route handlers, not at server/services/.
Nicety features (analytics, suggestion lookups, etc.) must never break a core path. Fire and forget, swallow the error, and log with warn.
The server is native ESM ("type": "module"). Local imports of TypeScript files use the .js extension: import authAPI from "./api/auth.js" resolves to auth.ts. Always write .js in local import specifiers even when the target is .ts. Missing this can break the build.
Mixed .js and .ts in the server.allowJs is on and several active route files are still plain JavaScript (api.js, api/projects.js, api/users.js). Both are first-class. New code is TypeScript, and the server tsconfig is strict with noImplicitAny.
Conventional Commits are enforced. Husky's commit-msg hook runs commitlint against @commitlint/config-conventional, and semantic-release cuts versions from master based on commit types. Match the existing style: fix(a11y): ..., feat(projects): ..., chore(deps): ....
index.jsx mounts Platform.tsx, which configures global Axios, wraps the app in React Query, Redux, and the context providers, and splits routing three ways with a <Switch>:
- Commons (
Commons.tsx) for the public catalog, collections, book, and library routes - Standalone (
Standalone.jsx) for isolated pages such as/adopt,/accessibility, and peer-review submission - Conductor (
Conductor.jsx) for the authenticated application, and the catch-all fallback
Components are grouped by domain under client/src/components/, screens under client/src/screens/{commons,conductor}, providers under client/src/providers/, and contexts under client/src/context/.
server/api/kb.ts together with services/agent.ts and services/ai-service.ts build on LangChain and LangGraph with a Qdrant vector store and OpenAI. The agent graph is sketched in server/agent-graph.mmd. This is an experimental feature for support and knowledge base search, and is not yet a production-facing.
Production is a multi-stage Docker build on Node 22 Alpine: build the client, build the server, then a production image that runs node dist/server.js, serves the built client from client/dist, exposes port 5000, and answers /health with a Mongo connectivity check. New Relic instrumentation loads only when NODE_ENV === "production".
Runtime frontend configuration is injected at /env.js as window.__APP_ENV__ rather than baked into the bundle, and only variables prefixed with CLIENT__ are exposed to the frontend. This is used as an alternative to Vite's import.meta.env for runtime configuration, because Vite's env is baked into the bundle at build time.
Knowing what is on the way out is as useful as knowing the standards:
- Semantic UI React and Tailwind v4 still ship alongside Davis during the migration. Do not write new Semantic UI.
NextGenComponents/,NextGenInputs/, andControlledInputs/are the pre-Davis in-house component sets.NextGenInputsis slated for deprecation. Replace them with Davis equivalents as you go.- Direct
axioscalls in components predatesrc/api.ts. Move them into the API class when you touch them. express-validatorchains predate Zod. Convert them when you touch the route.server/migrations/*are standalone scripts for data backfills and transforms, run manually rather than by a migration runner.