LMS Integration
This document describes the pluggable LMS (Learning Management System) integration in Notebooks Hub: how the architecture fits together, how configuration and routing work, and how to add a new LMS provider.
Overview
The LMS stack is a gateway over vendor REST APIs. Runtime wiring is driven by MongoDB config. Application code never calls Docebo (or a future vendor) directly—it uses a normalized LmsGateway contract.
Main consumers:
Admin APIs / Admin Console — CRUD for LMS implementations, active route, and live course lookup
LmsSyncService— maps LMS course enrollments to OpenFGA group membershipJupyterHub user init — looks up courses for the signed-in user via the router
Only one provider type is shipped today (docebo). Additional vendors are added by implementing a datasource + adapter and registering them in the factory and registry (see Adding a new LMS datasource).
Architecture
Request path
Caller uses
LmsRouterServicewith an optionalimplementationKey, or the active route key.Router asks
DynamicLmsRegistry.getGateway(key).Registry loads
LmsImplementationConfig, rejects disabled implementations (503), and obtains anAuthorizationheader fromLmsTokenManager.On cache miss (or config/auth signature change), the registry builds a LoopBack REST datasource, wraps it with
createLmsGateway, and caches the result.Caller invokes normalized
LmsGatewaymethods.
Gateway contract
Defined in [packages/API/src/services/lms-gateway.types.ts](../../../packages/API/src/services/lms-gateway.types.ts):
export interface LmsGateway {
getUsers(username?: string): Promise<LmsUser[]>;
getCourses(name?: string): Promise<LmsCourse[]>;
getUsersByCourse(courseName: string): Promise<string[]>;
getCoursesByUser(username: string): Promise<LmsCourse[]>;
}
Semantics implementers must match:
getUsers/getCoursesreturn normalizedLmsUser/LmsCoursemodels.getUsersByCoursereturns deduped lowercase emails/usernames (string[]), notLmsUser[]. The parameter is the provider’s course identifier (sync passes course ids for Docebo despite thecourseNamename).getCoursesByUsertakes a username/email, resolves any vendor user id if needed, and returns courses.
Vendor JSON stays behind the adapter. REST datasources expose raw clients (Promise<unknown> or vendor-specific types); only adapters are application-facing.
Key components
Layer |
Location |
Role |
|---|---|---|
Contract |
|
|
Factory |
|
|
Registry |
|
Build/cache datasources + gateways; invalidate on config change |
Router |
|
Resolve active (or override) key; delegate gateway methods |
Token manager |
|
OAuth-style token fetch/cache; bearer header |
Sync |
|
Course enrollments → OpenFGA group members |
Docebo DS |
|
REST operations + |
Docebo adapter |
|
Normalize Docebo payloads |
Admin API |
|
JWT-protected config CRUD and route |
Sync API |
|
|
Services are registered as singletons in application.ts (LmsTokenManager, DynamicLmsRegistry, LmsRouterService, LmsSyncService).
Configuration and routing
Implementation config (lms_implementation_config)
Field |
Meaning |
|---|---|
|
Stable key (path param |
|
e.g. |
|
Vendor API base URL |
|
Nested |
|
|
|
|
Route config (lms_route_config)
Singleton document with activeImplementationKey. If missing, the repository auto-creates one (default key default).
PUT /lms/routerequires the target implementation to exist.Deleting an implementation that is currently active is blocked; switch the route first.
Changing the active route does not invalidate gateway caches (caches are keyed by implementation, not by “active”).
Auth and tokens
LmsTokenManager obtains a bearer token for each implementation:
If a cached token for the auth signature is still valid, reuse it.
Otherwise
POSTa JSON grant payload toauth.authUrl(grant_type, optionalclient_id/client_secret/username/password/scope).Cache
access_tokenusingexpires_in(default TTL 3600s). Concurrent refreshes per key are deduped.
The authorization header is baked into the REST datasource at build time. When the token or auth config changes, the registry signature changes and the datasource is rebuilt.
New implementations require auth.authUrl. On update, empty/omitted secret fields (token, password, clientSecret) are preserved from the previous document so the admin UI can omit them on edit.
Today only simple credential-based grants are fully implemented. Roadmap includes implementation of static API tokens and complex multi-step authentication flows (i.e., OAuth2.0).
Webhook Sync
POST /lms/update/courses → LmsSyncService.updateCourses():
Resolve the active implementation key and load its
coursesmappings.For each mapping, call
getUsersByCourse(course.id).For each returned email, create an OpenFGA member tuple for the mapped
group.id(409treated as already-member success).Courses are processed with
Promise.allSettledso one mapping failure does not abort the others at the outer level.
Course→group mappings live on the implementation config; they are not part of the gateway interface itself.
Admin REST surface
All admin routes use @authenticate('oauth-jwt'):
Method |
Path |
Behavior |
|---|---|---|
|
|
List configs |
|
|
One config |
|
|
Live courses via router ( |
|
|
Upsert; invalidates registry for that key |
|
|
Delete if not active route; invalidate |
|
|
Active route |
|
|
Set |
Sync:
Method |
Path |
Behavior |
|---|---|---|
|
|
Run course→group sync |
The admin Angular client lives in [packages/UI/src/app/services/lms/lms-admin.service.ts](../../../packages/UI/src/app/services/lms/lms-admin.service.ts).
Adding a new LMS datasource
Use the Docebo stack as the template. You do not need a new LoopBack injectable service per vendor if you follow the factory + adapter pattern; registration is the switch cases plus Mongo config.
1. Extend the implementation type enum
Add a value to LmsImplementation in [packages/API/src/models/lms/lms-implementation-config.model.ts](../../../packages/API/src/models/lms/lms-implementation-config.model.ts):
export const LmsImplementation = {
Docebo: 'docebo',
Acme: 'acme', // example
} as const;
Mirror the same constant and LMS_IMPLEMENTATION_TYPES in the UI ([lms-admin.service.ts](../../../packages/UI/src/app/services/lms/lms-admin.service.ts)). Keep backend and UI enums aligned.
2. Vendor client types
Add packages/API/src/datasources/lms/<vendor>-lms.client.types.ts with the raw REST client shape (four methods matching your operation templates, typically returning Promise<unknown>).
3. DataSource builder
Add packages/API/src/datasources/lms/<vendor>-lms.datasource.ts following buildDoceboLmsDataSource:
LoopBack
connector: 'rest'Operation templates (
RestOperationDefinition) for the four gateway operationsPass
authorizationHeaderinto default headers (same snapshot pattern as Docebo)Export a
build…helper and optionalfromLmsImplementationConfigmapper
Export the new modules from [packages/API/src/datasources/lms/index.ts](../../../packages/API/src/datasources/lms/index.ts).
4. Gateway adapter
Add packages/API/src/services/lms/adapters/<vendor>-lms-gateway.adapter.ts implementing LmsGateway:
Map vendor payloads →
LmsUser(userId,username, …) andLmsCourse(courseId,name, …)getUsersByCourse→ deduped lowercase emails/usernamesResolve username → vendor user id when the upstream API requires ids
Export from [packages/API/src/services/lms/adapters/index.ts](../../../packages/API/src/services/lms/adapters/index.ts).
5. Register in factory and registry
In [lms-gateway.factory.ts](../../../packages/API/src/services/lms/lms-gateway.factory.ts):
case LmsImplementation.Acme:
return new AcmeLmsGatewayAdapter(rawClient as AcmeLmsClient);
In DynamicLmsRegistry.buildDataSource:
case LmsImplementation.Acme:
return buildAcmeLmsDataSource({
...fromLmsImplementationConfig(config, `lms-${implementationKey}`),
authorizationHeader,
});
Unsupported types should continue to throw HttpErrors.NotImplemented.
6. Configure and activate
Via admin API (or Admin Console):
PUT /lms/implementations/{key}withimplementationType,baseUrl,auth(includeauthUrl), optionalcoursesmappings,enabled: true.PUT /lms/routewith{ "activeImplementationKey": "{key}" }.Optionally browse live courses:
GET /lms/implementations/{key}/courses.Run sync when mappings are ready:
POST /lms/update/courses.
7. Tests
Mirror existing unit tests under packages/API/src/__tests__/unit/ for:
datasource builder / client types
adapter normalization
registry
buildDataSourceswitch / cache invalidation
Gotchas
Two-layer client: Keep vendor JSON parsing in adapters, not controllers or sync.
Docebo domain model: Docebo “courses” map to orgchart branches (
/manage/v1/orgchart…), not a separate learning-course catalog.Datasource auth is snapshot-based: Token is fixed in DS headers until the registry signature changes and rebuilds.
Secret-preserving upsert: Empty secret fields on update do not wipe stored values.
Cannot delete the active implementation: Switch
PUT /lms/routefirst.Enum duplication: Backend and Angular both define
LmsImplementation; update both when adding a provider.